mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Merge branch 'main' into feat/courier
Resolve DeliveryStatusView conflict: adopt main's bitchatDescription extension (tooltip + VoiceOver via body modifiers) and fold the .carried case into it, dropping the branch's Strings enum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,10 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||
# default (observed: intermittent app-suite hangs starving the queue).
|
||||
timeout-minutes: 15
|
||||
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||
timeout-minutes: 25
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
@@ -102,9 +105,14 @@ jobs:
|
||||
|
||||
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||
# catches algorithmic regressions, never runner variance.
|
||||
# catches algorithmic regressions, never runner variance. If a metric
|
||||
# still lands below floor (a saturated runner can dip one), the script
|
||||
# re-runs the benchmarks — appending to the same log and keeping each
|
||||
# benchmark's best value per metric — so noise clears on retry while a
|
||||
# real regression fails every attempt. Floors are never lowered by this.
|
||||
- name: Performance floor gate
|
||||
if: matrix.name == 'app'
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
@@ -145,3 +153,27 @@ jobs:
|
||||
ARCHS=arm64 \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
lint:
|
||||
name: SwiftLint (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
# privilege we can: a read-only token, and no credentials left in the
|
||||
# checkout for the container to find.
|
||||
permissions:
|
||||
contents: read
|
||||
container:
|
||||
# Tag for readability, digest for immutability (tags can be repointed).
|
||||
# Bump both together, deliberately — never a floating tag.
|
||||
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
- build
|
||||
- localPackages/*/.build
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- type_name
|
||||
- identifier_name
|
||||
- statement_position
|
||||
- implicit_optional_initialization
|
||||
- force_try
|
||||
- vertical_whitespace
|
||||
- for_where
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- type_body_length
|
||||
- file_length
|
||||
- large_tuple
|
||||
- force_cast
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
+2
-2
@@ -13,9 +13,9 @@ let package = Package(
|
||||
.executable(
|
||||
name: "bitchat",
|
||||
targets: ["bitchat"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies:[
|
||||
dependencies: [
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
|
||||
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.sendMessage(message)
|
||||
}
|
||||
|
||||
/// Resends a failed private message through the normal send path,
|
||||
/// removing the failed original so the re-submission replaces it
|
||||
/// instead of stacking a duplicate under the red bubble.
|
||||
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
||||
chatViewModel.removePrivateMessage(withID: message.id)
|
||||
chatViewModel.sendMessage(message.content)
|
||||
}
|
||||
|
||||
func clearCurrentConversation() {
|
||||
chatViewModel.sendMessage("/clear")
|
||||
}
|
||||
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
|
||||
if let peerID, peerID.isGeoChat,
|
||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
||||
// Mesh: block the peer's stable Noise identity resolved from the
|
||||
// tapped peerID rather than re-resolving a display-name string.
|
||||
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
} else {
|
||||
chatViewModel.sendMessage("/block \(displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
||||
/// the tapped peer's stable identity so the exact row is unblocked — this
|
||||
/// also works for offline peers, which the `/unblock <displayName>` command
|
||||
/// cannot resolve.
|
||||
func unblock(peerID: PeerID, displayName: String) {
|
||||
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct BitchatApp: App {
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
|
||||
}
|
||||
|
||||
enum TrustLevel: String, Codable {
|
||||
case unknown = "unknown"
|
||||
case casual = "casual"
|
||||
case trusted = "trusted"
|
||||
case verified = "verified"
|
||||
case unknown
|
||||
case casual
|
||||
case trusted
|
||||
case verified
|
||||
}
|
||||
|
||||
// MARK: - Identity Cache
|
||||
|
||||
+915
-158
File diff suppressed because it is too large
Load Diff
@@ -11,33 +11,38 @@ import Foundation
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||
// the suggestion panel is the app's only command-discovery surface, and
|
||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||
case block
|
||||
case clear
|
||||
case help
|
||||
case hug
|
||||
case message = "dm"
|
||||
case message = "msg"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||
// The processor rejects favorites in geohash contexts, so only
|
||||
// suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
return baseCommands
|
||||
}
|
||||
return baseCommands
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ private extension NostrProtocol {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data("nip44-v2".utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
@@ -132,4 +132,3 @@ private extension Data {
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
case .city: return 5
|
||||
case .province: return 4
|
||||
case .region: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
|
||||
@@ -1326,7 +1326,7 @@ extension BLEService: GossipSyncManager.Delegate {
|
||||
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
#if os(iOS)
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||
@@ -2008,7 +2008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||
|
||||
@@ -2849,7 +2849,7 @@ extension BLEService {
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed")))
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3108,10 +3108,9 @@ extension BLEService {
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
|
||||
case .none:
|
||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||
break
|
||||
}
|
||||
|
||||
if forwardAlongRouteIfNeeded(packet) {
|
||||
|
||||
@@ -105,11 +105,28 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-only command reference, printed as a system message. The
|
||||
/// suggestion panel hides once arguments are typed, and typos used to
|
||||
/// dead-end in a bare "unknown command" — this is the way out.
|
||||
static let helpText = """
|
||||
commands:
|
||||
/msg @name [message] — start a private chat
|
||||
/who — list who's here
|
||||
/clear — clear this chat
|
||||
/hug @name — send a hug
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/help — this list
|
||||
"""
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
|
||||
@@ -86,7 +86,7 @@ final class MessageRouter {
|
||||
}
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
note.userInfo?["isKeyUpdate"] is Bool {
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
|
||||
@@ -471,7 +471,7 @@ final class NoiseEncryptionService {
|
||||
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
||||
var out = Data()
|
||||
// context
|
||||
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
||||
let context = Data("bitchat-announce-v1".utf8)
|
||||
out.append(UInt8(min(context.count, 255)))
|
||||
out.append(context.prefix(255))
|
||||
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
||||
@@ -487,7 +487,7 @@ final class NoiseEncryptionService {
|
||||
out.append(ed32)
|
||||
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
|
||||
// nickname length + bytes
|
||||
let nickData = nickname.data(using: .utf8) ?? Data()
|
||||
let nickData = Data(nickname.utf8)
|
||||
out.append(UInt8(min(nickData.count, 255)))
|
||||
out.append(nickData.prefix(255))
|
||||
// timestamp
|
||||
|
||||
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
|
||||
@@ -111,7 +111,7 @@ final class NotificationService {
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
if granted {
|
||||
// Permission granted
|
||||
} else {
|
||||
|
||||
@@ -257,7 +257,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/// Block or unblock a mesh peer by its stable Noise identity.
|
||||
///
|
||||
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
|
||||
/// (cache / mesh session / known-peer Noise key). This works even when the
|
||||
/// peer is offline — including offline favorites — so the exact tapped peer
|
||||
/// is (un)blocked unambiguously instead of being re-resolved by a
|
||||
/// display-name string that two peers could share.
|
||||
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
|
||||
@discardableResult
|
||||
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
identityManager.setBlocked(fingerprint, isBlocked: blocked)
|
||||
updatePeers()
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
|
||||
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ final class ChatNostrCoordinator {
|
||||
key: Data?
|
||||
) {
|
||||
guard let context else { return }
|
||||
if let _ = key {
|
||||
if key != nil {
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if key != nil {
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
|
||||
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
}
|
||||
|
||||
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
|
||||
// command, these resolve and persist the block by the peer's stable
|
||||
// fingerprint (derived from `peerID`), so the exact tapped peer is
|
||||
// (un)blocked — unambiguous across nickname collisions and functional for
|
||||
// offline peers that can no longer be resolved through the mesh service.
|
||||
@MainActor
|
||||
func blockMeshPeer(peerID: PeerID, displayName: String) {
|
||||
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unblockMeshPeer(peerID: PeerID, displayName: String) {
|
||||
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
|
||||
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
|
||||
addCommandOutput(
|
||||
String(
|
||||
format: String(
|
||||
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
|
||||
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
|
||||
),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
addCommandOutput(
|
||||
String(
|
||||
format: String(
|
||||
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
|
||||
comment: "System message shown when a mesh peer is blocked or unblocked"
|
||||
),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
||||
}
|
||||
@@ -1438,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
/// Processes IRC-style commands starting with '/'.
|
||||
/// - Parameter command: The full command string including the leading slash
|
||||
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
|
||||
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help
|
||||
@MainActor
|
||||
func handleCommand(_ command: String) {
|
||||
let result = commandProcessor.process(command)
|
||||
@@ -1446,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
switch result {
|
||||
case .success(let message):
|
||||
if let msg = message {
|
||||
addSystemMessage(msg)
|
||||
addCommandOutput(msg)
|
||||
}
|
||||
case .error(let message):
|
||||
addSystemMessage(message)
|
||||
addCommandOutput(message)
|
||||
case .handled:
|
||||
// Command was handled, no message needed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Command output belongs in the conversation where the user typed the
|
||||
/// command; the public timeline is invisible while a DM is open. The DM
|
||||
/// selection is read *after* processing so commands that switch chats
|
||||
/// (`/msg`) print into the conversation they just opened.
|
||||
@MainActor
|
||||
private func addCommandOutput(_ content: String) {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
addLocalPrivateSystemMessage(content, to: peerID)
|
||||
} else {
|
||||
addSystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Reception
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -100,7 +100,7 @@ private extension ChatViewModelBootstrapper {
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(
|
||||
.failed(reason: "Not delivered"),
|
||||
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
forMessageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,26 @@ struct AppInfoView: View {
|
||||
)
|
||||
}
|
||||
|
||||
enum Legend {
|
||||
static let title: LocalizedStringKey = "app_info.legend.title"
|
||||
/// Every glyph the peer lists and headers use, in one place —
|
||||
/// nothing else in the app defines them.
|
||||
static let items: [(icon: String, text: LocalizedStringKey)] = [
|
||||
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
|
||||
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
|
||||
("globe", "app_info.legend.nostr"),
|
||||
("person", "app_info.legend.offline"),
|
||||
("mappin.and.ellipse", "app_info.legend.location_nearby"),
|
||||
("face.dashed", "app_info.legend.teleported"),
|
||||
("lock.fill", "app_info.legend.encrypted"),
|
||||
("lock.slash", "app_info.legend.encryption_failed"),
|
||||
("checkmark.seal.fill", "app_info.legend.verified"),
|
||||
("star.fill", "app_info.legend.favorite"),
|
||||
("envelope.fill", "app_info.legend.unread"),
|
||||
("nosign", "app_info.legend.blocked")
|
||||
]
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
@@ -118,14 +138,8 @@ struct AppInfoView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("app_info.close")
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +216,28 @@ struct AppInfoView: View {
|
||||
FeatureRow(info: Strings.Features.mentions)
|
||||
}
|
||||
|
||||
// Symbols legend
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(Strings.Legend.title)
|
||||
|
||||
ForEach(Strings.Legend.items, id: \.icon) { item in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: item.icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
Text(item.text)
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
|
||||
@@ -14,29 +14,46 @@ struct CommandSuggestionsView: View {
|
||||
|
||||
@Binding var messageText: String
|
||||
|
||||
/// The command already typed in full, once arguments have begun.
|
||||
private var typedCommandAlias: String? {
|
||||
guard messageText.hasPrefix("/"),
|
||||
let spaceIndex = messageText.firstIndex(of: " ")
|
||||
else { return nil }
|
||||
return String(messageText[..<spaceIndex]).lowercased()
|
||||
}
|
||||
|
||||
private var filteredCommands: [CommandInfo] {
|
||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||
guard messageText.hasPrefix("/") else { return [] }
|
||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
|
||||
// While arguments are being typed, keep the matched command's usage
|
||||
// row visible instead of vanishing at the first space.
|
||||
if let typed = typedCommandAlias {
|
||||
return commands.filter { $0.alias == typed && $0.placeholder != nil }
|
||||
}
|
||||
return commands.filter { command in
|
||||
command.alias.starts(with: messageText.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
// Render nothing when there are no matches: a zero-height view would
|
||||
// still receive the composer VStack's spacing and push the input row
|
||||
// off-center.
|
||||
if !filteredCommands.isEmpty {
|
||||
let isUsageReminder = typedCommandAlias != nil
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(filteredCommands) { command in
|
||||
Button {
|
||||
// In usage-reminder mode the row is informational; an
|
||||
// insert here would wipe the arguments being typed.
|
||||
guard !isUsageReminder else { return }
|
||||
messageText = command.alias + " "
|
||||
} label: {
|
||||
buttonRow(for: command)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.themedOverlayPanel()
|
||||
|
||||
@@ -9,6 +9,47 @@
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
extension DeliveryStatus {
|
||||
/// Localized, user-facing description of the status. Used for macOS
|
||||
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver —
|
||||
/// the glyphs alone are unexplained 10pt icons.
|
||||
var bitchatDescription: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
case .carried:
|
||||
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
|
||||
case .delivered(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .read(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .failed(let reason):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@ThemedPalette private var palette
|
||||
let status: DeliveryStatus
|
||||
@@ -19,66 +60,34 @@ struct DeliveryStatusView: View {
|
||||
|
||||
private var secondaryTextColor: Color { palette.secondary }
|
||||
|
||||
private enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static var carried: String {
|
||||
String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Tooltip for messages handed to a courier for physical delivery")
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
|
||||
var body: some View {
|
||||
statusGlyph
|
||||
.help(status.bitchatDescription)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(status.bitchatDescription)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusGlyph: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
|
||||
case .carried:
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
.help(Strings.carried)
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
case .delivered:
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@@ -86,24 +95,22 @@ struct DeliveryStatusView: View {
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
|
||||
case .read:
|
||||
// Filled variant so read vs delivered is legible without color.
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||
}
|
||||
.foregroundColor(palette.accentBlue)
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
@@ -112,7 +119,6 @@ struct DeliveryStatusView: View {
|
||||
.bitchatFont(size: 10)
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help(Strings.deliveredToMembers(reached, total))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
|
||||
|
||||
private var fgColor: Color { palette.primary }
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// SheetCloseButton.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The close "X" every sheet and header shares. One glyph size and weight
|
||||
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
|
||||
/// so existing header metrics don't move, and a hit target extended to 44pt
|
||||
/// per platform guidelines. Tint comes from the environment, so callers keep
|
||||
/// their own foreground color.
|
||||
struct SheetCloseButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import BitFoundation
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@@ -24,6 +25,7 @@ struct TextMessageView: View {
|
||||
/// the enum makes the change visible to SwiftUI's structural diff.
|
||||
private let deliveryStatus: DeliveryStatus?
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
init(message: BitchatMessage) {
|
||||
self.message = message
|
||||
@@ -35,19 +37,59 @@ struct TextMessageView: View {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuLinks = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Baseline alignment keeps the lock and delivery glyphs on the
|
||||
// first text line; a fixed top padding left the lock's solid body
|
||||
// hanging below the line's visual center.
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
if message.isPrivate {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.orange.opacity(0.75))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
// Delivery status indicator for private messages. Tappable:
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +102,7 @@ struct TextMessageView: View {
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
.foregroundColor(Color.blue)
|
||||
.foregroundColor(palette.accentBlue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
@@ -78,6 +120,12 @@ struct TextMessageView: View {
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
// Collapse the revealed caption when the status advances (e.g.
|
||||
// sending → sent → delivered) so a detail opened for one state
|
||||
// doesn't linger and silently morph into another.
|
||||
.onChange(of: deliveryStatus) { _ in
|
||||
showDeliveryDetail = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import UIKit
|
||||
struct ContentComposerView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
|
||||
@@ -43,7 +44,6 @@ struct ContentComposerView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.themedOverlayPanel()
|
||||
@@ -60,10 +60,8 @@ struct ContentComposerView: View {
|
||||
TextField(
|
||||
"",
|
||||
text: $messageText,
|
||||
prompt: Text(
|
||||
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
||||
)
|
||||
.foregroundColor(palette.secondary.opacity(0.6))
|
||||
prompt: Text(placeholderText)
|
||||
.foregroundColor(palette.secondary.opacity(0.6))
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.bitchatFont(size: 15)
|
||||
@@ -110,6 +108,33 @@ struct ContentComposerView: View {
|
||||
}
|
||||
|
||||
private extension ContentComposerView {
|
||||
/// States where a message will land: the DM partner's name for private
|
||||
/// chats, the channel (and its public nature) otherwise — so a stressed
|
||||
/// user never has to guess who can read what they're typing.
|
||||
var placeholderText: String {
|
||||
if let header = privateConversationModel.selectedHeaderState {
|
||||
// A geohash-DM display name already carries its own "#geohash/@name"
|
||||
// form, so it must not get another "@" prefix; a mesh nickname does.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
locale: .current,
|
||||
target
|
||||
)
|
||||
}
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
|
||||
case .location(let channel):
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var recordingIndicator: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
@@ -158,7 +183,19 @@ private extension ContentComposerView {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel("Tap for library, long press for camera")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// The long-press → camera path is unreachable for VoiceOver users;
|
||||
// mirror it as a named action.
|
||||
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
#else
|
||||
Button(action: { showMacImagePicker = true }) {
|
||||
Image(systemName: "photo.circle.fill")
|
||||
@@ -167,7 +204,9 @@ private extension ContentComposerView {
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Choose photo")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -209,7 +248,27 @@ private extension ContentComposerView {
|
||||
}
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Hold to record a voice note")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
|
||||
)
|
||||
.accessibilityValue(
|
||||
voiceRecordingVM.state.isActive
|
||||
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
: ""
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||
// give it a start/stop toggle as the default action.
|
||||
.accessibilityAction {
|
||||
if voiceRecordingVM.state.isActive {
|
||||
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
|
||||
} else {
|
||||
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendButtonView(enabled: Bool) -> some View {
|
||||
|
||||
@@ -36,6 +36,16 @@ struct ContentHeaderView: View {
|
||||
.onTapGesture(count: 1) {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
// This is the only entry point to App Info, but it reads as
|
||||
// static text; surface the tap. (The triple-tap panic wipe
|
||||
// stays undiscoverable on purpose — it's destructive.)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
)
|
||||
.accessibilityAction {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "@")
|
||||
@@ -203,6 +213,13 @@ struct ContentHeaderView: View {
|
||||
headerOtherPeersCount
|
||||
)
|
||||
)
|
||||
// Connected-vs-nobody is otherwise encoded only in the icon's
|
||||
// color; say it.
|
||||
.accessibilityValue(
|
||||
headerPeersReachable
|
||||
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
|
||||
)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
.sheet(isPresented: $showVerifySheet) {
|
||||
@@ -210,6 +227,11 @@ struct ContentHeaderView: View {
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
// Fixed height is load-bearing: children fill the bar with
|
||||
// .frame(maxHeight: .infinity) tap targets, so an open-ended
|
||||
// minHeight lets the header expand to swallow the whole screen.
|
||||
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
|
||||
// Type.
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||
@@ -289,14 +311,25 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// Whether anyone is actually reachable on the current channel — the
|
||||
/// state the count icon's color encodes visually.
|
||||
var headerPeersReachable: Bool {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .location:
|
||||
return peerListModel.visibleGeohashPeerCount > 0
|
||||
case .mesh:
|
||||
return peerListModel.connectedMeshPeerCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .location:
|
||||
let count = peerListModel.visibleGeohashPeerCount
|
||||
return (count, count > 0 ? palette.locationAccent : Color.secondary)
|
||||
return (count, count > 0 ? palette.locationAccent : palette.secondary)
|
||||
case .mesh:
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
|
||||
return (peerListModel.reachableMeshPeerCount, color)
|
||||
}
|
||||
}
|
||||
@@ -316,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
|
||||
Text("content.notes.title")
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
Spacer()
|
||||
Button(action: { showLocationNotes = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.foregroundColor(palette.primary)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
SheetCloseButton { showLocationNotes = false }
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.themedChromePanel(edge: .top)
|
||||
Text("content.notes.location_unavailable")
|
||||
|
||||
@@ -134,6 +134,7 @@ private struct ContentPeopleListView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -159,24 +160,23 @@ private struct ContentPeopleListView: View {
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// .help maps to the accessibility *hint* on iOS, so the
|
||||
// button still needs a spoken name.
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
dismiss()
|
||||
showSidebar = false
|
||||
showVerifySheet = false
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
|
||||
let activeText = String.localizedStringWithFormat(
|
||||
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
|
||||
Text(subtitle)
|
||||
.foregroundColor(subtitleColor)
|
||||
Text(activeText)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.bitchatFont(size: 12)
|
||||
} else {
|
||||
Text(activeText)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -231,6 +231,13 @@ private struct ContentPeopleListView: View {
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
appChromeModel.showFingerprint(for: peerID)
|
||||
},
|
||||
onToggleBlock: { peer in
|
||||
if peer.isBlocked {
|
||||
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
|
||||
} else {
|
||||
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -333,6 +340,9 @@ private struct ContentPrivateChatSheetView: View {
|
||||
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
|
||||
// Same visual box + 44pt hit target as SheetCloseButton.
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
privateConversationModel.endConversation()
|
||||
showSidebar = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
// minHeight so scaled text at accessibility sizes grows the
|
||||
// bar instead of clipping inside it.
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
.modifier(PrivateHeaderChrome())
|
||||
}
|
||||
|
||||
MessageListView(
|
||||
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
Divider()
|
||||
}
|
||||
|
||||
privacyCaption
|
||||
|
||||
#if os(iOS)
|
||||
ContentComposerView(
|
||||
messageText: $messageText,
|
||||
@@ -421,6 +429,69 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Persistent one-line reminder that this composer feeds a private
|
||||
/// conversation — the DM sheet otherwise renders identically to the
|
||||
/// public timeline. Claims end-to-end encryption only once the session
|
||||
/// is actually secured.
|
||||
private var privacyCaption: some View {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 9))
|
||||
// Optical centering: lock.fill's ink is bottom-heavy, so
|
||||
// geometric centering reads low next to the caption text.
|
||||
.offset(y: -1)
|
||||
Text(verbatim: privacyCaptionText)
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
}
|
||||
.foregroundColor(Color.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
// The orange text is signature enough; a tinted band here reads as a
|
||||
// stray strip against the untinted composer chrome below it, so the
|
||||
// caption sits on the same surface as the rest of the bottom chrome.
|
||||
.themedSurface()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var privacyCaptionText: String {
|
||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let noiseSecured: Bool = {
|
||||
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||
case .noiseSecured, .noiseVerified: return true
|
||||
default: return false
|
||||
}
|
||||
}()
|
||||
if isGeoDM || noiseSecured {
|
||||
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
}
|
||||
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||
}
|
||||
}
|
||||
|
||||
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
|
||||
/// over an opaque themed surface. Glass gets the same floating panel as the
|
||||
/// main header instead: an orange wash over the backdrop gradient reads as a
|
||||
/// muddy gray-beige band, and the DM signature is already carried by the
|
||||
/// orange lock, caption, and composer accents.
|
||||
private struct PrivateHeaderChrome: ViewModifier {
|
||||
@Environment(\.appTheme) private var theme
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if theme.usesGlassChrome {
|
||||
content.themedChromePanel(edge: .top)
|
||||
} else {
|
||||
// Orange tint before themedSurface so it layers in front of the
|
||||
// opaque themed background rather than behind it.
|
||||
content
|
||||
.background(Color.orange.opacity(0.06))
|
||||
.themedSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPrivateHeaderInfoButton: View {
|
||||
@@ -452,17 +523,30 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
EmptyView()
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text("mesh_peers.state.offline")
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
|
||||
Text(headerState.displayName)
|
||||
.bitchatFont(size: 16, weight: .medium)
|
||||
.foregroundColor(palette.primary)
|
||||
// Middle truncation keeps the identity suffix visible on
|
||||
// long nicknames instead of wrapping into the fixed-height
|
||||
// header.
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
if let encryptionStatus = headerState.encryptionStatus,
|
||||
let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
// Optical centering: the lock glyphs' ink is bottom-heavy
|
||||
// (solid body, thin shackle), so geometric centering reads
|
||||
// ~1pt low next to the name. The seal badge is symmetric
|
||||
// and needs no lift.
|
||||
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
|
||||
.foregroundColor(
|
||||
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
|
||||
? palette.primary
|
||||
@@ -489,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(height: headerHeight)
|
||||
.frame(minHeight: headerHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,8 @@ struct FingerprintView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -83,7 +80,7 @@ struct FingerprintView: View {
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
// Their fingerprint
|
||||
@@ -101,7 +98,7 @@ struct FingerprintView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
@@ -135,7 +132,7 @@ struct FingerprintView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
|
||||
@@ -13,6 +13,13 @@ struct GeohashPeopleList: View {
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -46,7 +53,12 @@ struct GeohashPeopleList: View {
|
||||
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
|
||||
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
|
||||
let rowColor: Color = person.isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
Image(systemName: icon)
|
||||
// Size 10 to match the mesh rows' leading glyphs —
|
||||
// both lists share the sidebar.
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(rowColor)
|
||||
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||
|
||||
let (base, suffix) = person.displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
@@ -54,6 +66,8 @@ struct GeohashPeopleList: View {
|
||||
.bitchatFont(size: 14)
|
||||
.fontWeight(person.isMe ? .bold : .regular)
|
||||
.foregroundColor(rowColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
@@ -105,6 +119,27 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: person))
|
||||
.accessibilityAddTraits(person.isMe ? [] : .isButton)
|
||||
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
|
||||
.accessibilityActions {
|
||||
if !person.isMe {
|
||||
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
|
||||
if person.isBlocked {
|
||||
peerListModel.unblockGeohashUser(
|
||||
pubkeyHexLowercased: person.id,
|
||||
displayName: person.displayName
|
||||
)
|
||||
} else {
|
||||
peerListModel.blockGeohashUser(
|
||||
pubkeyHexLowercased: person.id,
|
||||
displayName: person.displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
@@ -119,4 +154,13 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One spoken sentence per row: name, presence type, and block state.
|
||||
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
|
||||
var parts: [String] = [person.displayName]
|
||||
if person.isMe { parts.append(Strings.youState) }
|
||||
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||
if person.isBlocked { parts.append(Strings.blockedState) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
@@ -103,7 +106,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
Group {
|
||||
switch locationChannelsModel.permissionState {
|
||||
@@ -122,7 +125,7 @@ struct LocationChannelsSheet: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.permissionDenied)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
SheetCloseButton { isPresented = false }
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
|
||||
) {
|
||||
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
|
||||
HStack(spacing: 2) {
|
||||
Text(verbatim: "#")
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
|
||||
.bitchatFont(size: 14)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.background(palette.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||
let level = levelForLength(gh.count)
|
||||
@@ -357,7 +357,10 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
|
||||
) {
|
||||
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||||
@@ -399,6 +402,8 @@ struct LocationChannelsSheet: View {
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
accessoryActionTitle: String? = nil,
|
||||
accessoryAction: (() -> Void)? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
@@ -409,17 +414,17 @@ struct LocationChannelsSheet: View {
|
||||
Text(parts.base)
|
||||
.bitchatFont(size: 14)
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
.foregroundColor(titleColor ?? palette.primary)
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||
Text(subtitleFull)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
@@ -434,6 +439,19 @@ struct LocationChannelsSheet: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
// The row is a plain HStack with a tap gesture, which VoiceOver reads
|
||||
// as disconnected static text. Expose it as one activatable button;
|
||||
// the visible bookmark accessory is mirrored as a named action.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
|
||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
|
||||
.accessibilityHint(Strings.switchChannelHint)
|
||||
.accessibilityAction(.default, action)
|
||||
.accessibilityActions {
|
||||
if let accessoryActionTitle, let accessoryAction {
|
||||
Button(accessoryActionTitle, action: accessoryAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -477,16 +495,16 @@ extension LocationChannelsSheet {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Strings.torTitle)
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(Strings.torSubtitle)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.background(palette.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ struct LocationNotesView: View {
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
private enum Strings {
|
||||
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.closeAccessibility)
|
||||
SheetCloseButton { dismiss() }
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
|
||||
if !ts.isEmpty {
|
||||
Text(ts)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -197,7 +190,7 @@ struct LocationNotesView: View {
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.relaysRetryHint)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.retry) { manager.refresh() }
|
||||
.bitchatFont(size: 12)
|
||||
.buttonStyle(.plain)
|
||||
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNotes)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@@ -222,7 +215,7 @@ struct LocationNotesView: View {
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.emptySubtitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
struct BlockRevealImageView: View {
|
||||
@ThemedPalette private var palette
|
||||
private let url: URL
|
||||
private let revealProgress: Double?
|
||||
private let isSending: Bool
|
||||
@@ -20,6 +21,25 @@ struct BlockRevealImageView: View {
|
||||
@State private var platformImage: PlatformImage?
|
||||
@State private var aspectRatio: CGFloat = 1
|
||||
@State private var isBlurred: Bool = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var loadFailed = false
|
||||
|
||||
private enum Strings {
|
||||
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
|
||||
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
|
||||
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
|
||||
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
|
||||
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
|
||||
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
|
||||
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
|
||||
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
|
||||
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
|
||||
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
|
||||
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
||||
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
|
||||
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
|
||||
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
}
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
@@ -69,20 +89,32 @@ struct BlockRevealImageView: View {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.35))
|
||||
.overlay(
|
||||
Image(systemName: "eye.slash.fill")
|
||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "eye.slash.fill")
|
||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||
Text(verbatim: Strings.tapToReveal)
|
||||
// Themed: monospaced under matrix,
|
||||
// system under liquid glass.
|
||||
.bitchatFont(size: 12, weight: .medium)
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.fill(palette.secondary.opacity(0.2))
|
||||
.frame(height: 200)
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
)
|
||||
.overlay {
|
||||
if loadFailed {
|
||||
Image(systemName: "photo")
|
||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||
.foregroundColor(palette.secondary)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
|
||||
.padding(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.cancelSend)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
|
||||
loadImage()
|
||||
}
|
||||
.gesture(mainGesture)
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
#else
|
||||
guard let image = NSImage(contentsOf: url) else { return }
|
||||
#endif
|
||||
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||
DispatchQueue.main.async {
|
||||
self.platformImage = image
|
||||
self.aspectRatio = ratio
|
||||
.contextMenu {
|
||||
if isSending {
|
||||
cancelSendAction
|
||||
} else {
|
||||
imageActions
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Strings.deleteConfirmTitle,
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(Strings.delete, role: .destructive) {
|
||||
onDelete?()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(verbatim: Strings.deleteConfirmMessage)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabelText)
|
||||
.accessibilityHint(accessibilityHintText)
|
||||
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
|
||||
.accessibilityActions {
|
||||
if isSending {
|
||||
// children: .ignore collapses the visible cancel button, so
|
||||
// expose it as an action while the send is in flight.
|
||||
cancelSendAction
|
||||
} else {
|
||||
imageActions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainGesture: some Gesture {
|
||||
let doubleTap = TapGesture(count: 2).onEnded {
|
||||
guard !isSending else { return }
|
||||
onDelete?()
|
||||
@ViewBuilder
|
||||
private var cancelSendAction: some View {
|
||||
if let onCancel {
|
||||
Button(Strings.cancelSend, action: onCancel)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageActions: some View {
|
||||
// Open/reveal/hide would act on a file that failed to load, so only
|
||||
// offer delete (when available) to let users clean up the attachment.
|
||||
if !loadFailed {
|
||||
if isBlurred {
|
||||
Button(Strings.reveal) {
|
||||
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
|
||||
}
|
||||
} else {
|
||||
Button(Strings.open) { onOpen?() }
|
||||
Button(Strings.hide) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
if onDelete != nil {
|
||||
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityLabelText: String {
|
||||
if isSending { return Strings.sendingImage }
|
||||
if loadFailed { return Strings.unavailableImage }
|
||||
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
|
||||
}
|
||||
|
||||
private var accessibilityHintText: String {
|
||||
if isSending || loadFailed { return "" }
|
||||
return isBlurred ? Strings.revealHint : Strings.openHint
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
loadFailed = false
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
let image = UIImage(contentsOfFile: url.path)
|
||||
#else
|
||||
let image = NSImage(contentsOf: url)
|
||||
#endif
|
||||
DispatchQueue.main.async {
|
||||
guard let image else {
|
||||
self.loadFailed = true
|
||||
return
|
||||
}
|
||||
self.platformImage = image
|
||||
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Double-tap used to permanently delete the image — the most ingrained
|
||||
// photo gesture on mobile, racing the reveal tap, with no confirmation
|
||||
// and no way to get the file back. Delete now lives in the context menu
|
||||
// behind a confirmation; taps only reveal and open.
|
||||
private var mainGesture: some Gesture {
|
||||
let singleTap = TapGesture().onEnded {
|
||||
guard !isSending else { return }
|
||||
guard !isSending, !loadFailed else { return }
|
||||
if isBlurred {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
isBlurred = false
|
||||
@@ -139,7 +247,7 @@ struct BlockRevealImageView: View {
|
||||
}
|
||||
}
|
||||
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
||||
guard !isSending else { return }
|
||||
guard !isSending, !loadFailed else { return }
|
||||
let horizontal = value.translation.width
|
||||
let vertical = value.translation.height
|
||||
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
||||
@@ -149,7 +257,7 @@ struct BlockRevealImageView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
||||
return singleTap.simultaneously(with: swipe)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import BitFoundation
|
||||
struct MediaMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
let message: BitchatMessage
|
||||
let media: BitchatMessage.Media
|
||||
@@ -20,6 +21,7 @@ struct MediaMessageView: View {
|
||||
/// fields by identity, so without the snapshot a status-only change
|
||||
/// (send progress, delivered → read) would not re-render this row.
|
||||
private let deliveryStatus: DeliveryStatus?
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
@Binding var imagePreviewURL: URL?
|
||||
|
||||
@@ -35,46 +37,93 @@ struct MediaMessageView: View {
|
||||
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
|
||||
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Baseline alignment (via the header text inside the VStack) keeps the
|
||||
// lock on the header line; a fixed top padding left its solid body
|
||||
// hanging below the line's visual center.
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
if message.isPrivate {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.orange.opacity(0.75))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Delivery status indicator for private messages. Tappable:
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
switch media {
|
||||
case .voice(let url):
|
||||
VoiceNoteView(
|
||||
url: url,
|
||||
isSending: state.isSending,
|
||||
sendProgress: state.progress,
|
||||
onCancel: cancelAction
|
||||
)
|
||||
case .image(let url):
|
||||
BlockRevealImageView(
|
||||
url: url,
|
||||
revealProgress: state.progress,
|
||||
isSending: state.isSending,
|
||||
onCancel: cancelAction,
|
||||
initiallyBlurred: !isFromMe,
|
||||
onOpen: {
|
||||
if !state.isSending {
|
||||
imagePreviewURL = url
|
||||
}
|
||||
},
|
||||
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||
)
|
||||
.frame(maxWidth: 280)
|
||||
Group {
|
||||
switch media {
|
||||
case .voice(let url):
|
||||
VoiceNoteView(
|
||||
url: url,
|
||||
isSending: state.isSending,
|
||||
sendProgress: state.progress,
|
||||
onCancel: cancelAction
|
||||
)
|
||||
case .image(let url):
|
||||
BlockRevealImageView(
|
||||
url: url,
|
||||
revealProgress: state.progress,
|
||||
isSending: state.isSending,
|
||||
onCancel: cancelAction,
|
||||
initiallyBlurred: !isFromMe,
|
||||
onOpen: {
|
||||
if !state.isSending {
|
||||
imagePreviewURL = url
|
||||
}
|
||||
},
|
||||
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
|
||||
)
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
// Collapse the revealed caption when the status advances (e.g.
|
||||
// sending → sent → delivered) so a detail opened for one state
|
||||
// doesn't linger and silently morph into another.
|
||||
.onChange(of: deliveryStatus) { _ in
|
||||
showDeliveryDetail = false
|
||||
}
|
||||
}
|
||||
|
||||
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
|
||||
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
||||
// Palette-based and slightly translucent so the card doesn't sit as
|
||||
// an opaque white/black box over the glass gradient.
|
||||
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
@@ -50,6 +52,12 @@ struct VoiceNoteView: View {
|
||||
.background(Circle().fill(palette.accent))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
playback.isPlaying
|
||||
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
|
||||
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
|
||||
)
|
||||
.accessibilityValue(playbackLabel)
|
||||
|
||||
WaveformView(
|
||||
samples: samples,
|
||||
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
|
||||
|
||||
Text(playbackLabel)
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(Color.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
Button(action: onCancel) {
|
||||
@@ -74,6 +82,9 @@ struct VoiceNoteView: View {
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
@@ -42,7 +42,7 @@ struct WaveformView: View {
|
||||
} else if let send = clampedSend, binPosition <= send {
|
||||
color = palette.accentBlue
|
||||
} else {
|
||||
color = Color.gray.opacity(0.35)
|
||||
color = palette.secondary.opacity(0.35)
|
||||
}
|
||||
context.fill(Path(rect), with: .color(color))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
|
||||
let onTapPeer: (PeerID) -> Void
|
||||
let onToggleFavorite: (PeerID) -> Void
|
||||
let onShowFingerprint: (PeerID) -> Void
|
||||
/// Optional so existing call sites (and previews/tests) keep compiling;
|
||||
/// when absent the block/unblock context-menu entry is hidden.
|
||||
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
|
||||
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
|
||||
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
.help(Strings.connected)
|
||||
} else if peer.isReachable {
|
||||
// Mesh-reachable (relayed): point.3 icon
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
.help(Strings.reachable)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
.help(Strings.nostr)
|
||||
} else {
|
||||
// Fallback icon for others (dimmed)
|
||||
Image(systemName: "person")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(palette.secondary)
|
||||
.help(Strings.offline)
|
||||
}
|
||||
|
||||
let (base, suffix) = peer.displayName.splitSuffix()
|
||||
@@ -71,6 +92,8 @@ struct MeshPeerList: View {
|
||||
Text(base)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(baseColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
@@ -91,6 +114,10 @@ struct MeshPeerList: View {
|
||||
if let icon = peer.encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
// Optical centering: lock glyph ink is
|
||||
// bottom-heavy, so geometric centering
|
||||
// reads low next to the name.
|
||||
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
} else {
|
||||
@@ -103,6 +130,7 @@ struct MeshPeerList: View {
|
||||
// Fallback to whatever status says (likely lock if we had a past session)
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +151,11 @@ struct MeshPeerList: View {
|
||||
Image(systemName: peer.isFavorite ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
|
||||
// Widen the tap target beyond the bare glyph;
|
||||
// height stays row-bound so neighboring rows
|
||||
// keep their own taps.
|
||||
.frame(width: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -131,8 +164,53 @@ struct MeshPeerList: View {
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||
// count:2 must attach before count:1 or the single tap
|
||||
// shadows it (same ordering the header logo relies on).
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||
.contextMenu {
|
||||
if !isMe {
|
||||
Button(Strings.directMessage) {
|
||||
onTapPeer(peer.peerID)
|
||||
}
|
||||
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
|
||||
onToggleFavorite(peer.peerID)
|
||||
}
|
||||
Button(Strings.showFingerprint) {
|
||||
onShowFingerprint(peer.peerID)
|
||||
}
|
||||
if let onToggleBlock {
|
||||
if peer.isBlocked {
|
||||
Button(Strings.unblock) {
|
||||
onToggleBlock(peer)
|
||||
}
|
||||
} else {
|
||||
Button(Strings.block, role: .destructive) {
|
||||
onToggleBlock(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: peer))
|
||||
.accessibilityAddTraits(isMe ? [] : .isButton)
|
||||
.accessibilityHint(isMe ? "" : Strings.openDMHint)
|
||||
.accessibilityActions {
|
||||
if !isMe {
|
||||
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
|
||||
onToggleFavorite(peer.peerID)
|
||||
}
|
||||
Button(Strings.showFingerprint) {
|
||||
onShowFingerprint(peer.peerID)
|
||||
}
|
||||
if let onToggleBlock {
|
||||
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
|
||||
onToggleBlock(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
@@ -147,4 +225,25 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One spoken sentence per row: name, how they're reachable, and any
|
||||
/// state badges — the visual row is icon soup for VoiceOver otherwise.
|
||||
private func accessibilityDescription(for peer: MeshPeerRow) -> String {
|
||||
var parts: [String] = [peer.displayName]
|
||||
if !peer.isMe {
|
||||
if peer.isConnected {
|
||||
parts.append(Strings.connected)
|
||||
} else if peer.isReachable {
|
||||
parts.append(Strings.reachable)
|
||||
} else if peer.isMutualFavorite {
|
||||
parts.append(Strings.nostr)
|
||||
} else {
|
||||
parts.append(Strings.offline)
|
||||
}
|
||||
}
|
||||
if peer.isFavorite { parts.append(Strings.favorite) }
|
||||
if peer.hasUnread { parts.append(Strings.unread) }
|
||||
if peer.isBlocked { parts.append(Strings.blocked) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,17 @@ struct MessageListView: View {
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
@State private var showMessageActions = false
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
@State private var unseenCount = 0
|
||||
@State private var lastSeenMessageCount = 0
|
||||
/// Context key the unseen counters were baselined against. Channel
|
||||
/// switches swap the timeline wholesale, so a count delta is only a
|
||||
/// "new messages" signal while the context is unchanged.
|
||||
@State private var unseenBaselineKey = ""
|
||||
|
||||
@ThemedPalette private var palette
|
||||
|
||||
var body: some View {
|
||||
let currentWindowCount: Int = {
|
||||
@@ -65,6 +74,9 @@ struct MessageListView: View {
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
if messageItems.isEmpty && privatePeer == nil {
|
||||
publicEmptyState
|
||||
}
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(messageItems) { item in
|
||||
let message = item.message
|
||||
@@ -72,6 +84,7 @@ struct MessageListView: View {
|
||||
.onAppear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
}
|
||||
if message.id == windowedMessages.first?.id,
|
||||
messages.count > windowedMessages.count {
|
||||
@@ -89,13 +102,32 @@ struct MessageListView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if message.sender != "system" {
|
||||
messageText = "@\(message.sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
|
||||
if showsUserActions {
|
||||
// Mention and DM are redundant inside a 1:1 conversation:
|
||||
// mentioning the only other participant is noise, and "DM"
|
||||
// would just reopen the conversation that is already open.
|
||||
if privatePeer == nil {
|
||||
Button("content.actions.mention") {
|
||||
insertMention(message.sender)
|
||||
}
|
||||
if let peerID = message.senderPeerID {
|
||||
Button("content.actions.direct_message") {
|
||||
privateConversationModel.openConversation(for: peerID)
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("content.actions.hug") {
|
||||
conversationUIModel.sendHug(to: message.sender)
|
||||
}
|
||||
Button("content.actions.slap") {
|
||||
conversationUIModel.sendSlap(to: message.sender)
|
||||
}
|
||||
}
|
||||
Button("content.message.copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
@@ -105,6 +137,16 @@ struct MessageListView: View {
|
||||
pb.setString(message.content, forType: .string)
|
||||
#endif
|
||||
}
|
||||
if isResendableFailedMessage(message) {
|
||||
Button("content.actions.resend") {
|
||||
conversationUIModel.resendFailedPrivateMessage(message)
|
||||
}
|
||||
}
|
||||
if showsUserActions {
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 1)
|
||||
@@ -113,9 +155,24 @@ struct MessageListView: View {
|
||||
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !isAtBottom && !messageItems.isEmpty {
|
||||
jumpToLatestPill(proxy: proxy)
|
||||
}
|
||||
}
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onTapGesture(count: 3) {
|
||||
conversationUIModel.clearCurrentConversation()
|
||||
showClearConfirmation = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"content.clear.confirm_title",
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.clear.confirm_action", role: .destructive) {
|
||||
conversationUIModel.clearCurrentConversation()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
@@ -139,9 +196,7 @@ struct MessageListView: View {
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
if let sender = selectedMessageSender {
|
||||
// Pre-fill the input with an @mention and focus the field
|
||||
messageText = "@\(sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
insertMention(sender)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +263,142 @@ struct MessageListView: View {
|
||||
}
|
||||
|
||||
private extension MessageListView {
|
||||
var currentContextKey: String {
|
||||
if let peer = privatePeer {
|
||||
return "dm:\(peer)"
|
||||
}
|
||||
return locationChannelsModel.selectedChannel.contextKey
|
||||
}
|
||||
|
||||
/// Terminal-styled narration for an empty public timeline: says which
|
||||
/// channel this is, that the app is waiting for peers, and where to go
|
||||
/// next. Rendered inside the ScrollView; disappears with the first row.
|
||||
var publicEmptyState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
case .location(let channel):
|
||||
emptyStateLine(
|
||||
String(
|
||||
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func emptyStateLine(_ text: String) -> some View {
|
||||
// Non-breaking space before the closing asterisk so a tight wrap
|
||||
// can't orphan a lone "*" onto its own line.
|
||||
Text(verbatim: "* \(text)\u{00A0}*")
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
/// Messages the unseen counters may book as "new": rows that render as
|
||||
/// human messages. System lines render as narration and whitespace-only
|
||||
/// content never renders at all, so neither belongs in the pill count.
|
||||
func unseenEligibleCount(in messages: [BitchatMessage]) -> Int {
|
||||
messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count
|
||||
}
|
||||
|
||||
/// Updates the unseen-count baseline for the current context and returns
|
||||
/// how many messages were appended since the last observation. A context
|
||||
/// change (timeline swapped wholesale) re-baselines and reports zero, so
|
||||
/// cross-channel count differences are never booked as "new" messages.
|
||||
func rebaselinedAppendedCount(newCount: Int) -> Int {
|
||||
let key = currentContextKey
|
||||
if unseenBaselineKey != key {
|
||||
unseenBaselineKey = key
|
||||
unseenCount = 0
|
||||
lastSeenMessageCount = newCount
|
||||
return 0
|
||||
}
|
||||
let appended = max(0, newCount - lastSeenMessageCount)
|
||||
lastSeenMessageCount = newCount
|
||||
return appended
|
||||
}
|
||||
|
||||
/// A failed private text message of our own can be resent through the
|
||||
/// normal send path (the context menu removes the failed original and
|
||||
/// re-submits its content).
|
||||
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
|
||||
guard message.isPrivate,
|
||||
conversationUIModel.isSentByCurrentUser(message),
|
||||
conversationUIModel.mediaAttachment(for: message) == nil,
|
||||
case .some(.failed) = message.deliveryStatus
|
||||
else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Appends an @mention to the composer draft (never overwrites what the
|
||||
/// user has already typed) and focuses the input field.
|
||||
func insertMention(_ sender: String) {
|
||||
let mention = "@\(sender) "
|
||||
if messageText.isEmpty {
|
||||
messageText = mention
|
||||
} else if messageText.hasSuffix(" ") {
|
||||
messageText += mention
|
||||
} else {
|
||||
messageText += " " + mention
|
||||
}
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
|
||||
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
|
||||
/// unseenCount state the view already tracks, and jumps to the newest
|
||||
/// message via the existing scrollToBottom helper.
|
||||
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
|
||||
Button {
|
||||
scrollToBottom(on: proxy)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.bitchatSystem(size: 11, weight: .semibold))
|
||||
if unseenCount > 0 {
|
||||
Text(
|
||||
String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
)
|
||||
.bitchatFont(size: 12, weight: .medium)
|
||||
}
|
||||
}
|
||||
.foregroundColor(palette.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedOverlayPanel()
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, 10)
|
||||
.accessibilityLabel(jumpToLatestAccessibilityLabel)
|
||||
}
|
||||
|
||||
var jumpToLatestAccessibilityLabel: String {
|
||||
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
|
||||
guard unseenCount > 0 else { return base }
|
||||
let count = String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
return "\(base), \(count)"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func messageRow(for message: BitchatMessage) -> some View {
|
||||
Group {
|
||||
@@ -294,6 +485,9 @@ private extension MessageListView {
|
||||
|
||||
func scrollToBottom(on proxy: ScrollViewProxy) {
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
|
||||
unseenBaselineKey = currentContextKey
|
||||
if let targetPeerID {
|
||||
proxy.scrollTo(targetPeerID, anchor: .bottom)
|
||||
}
|
||||
@@ -316,15 +510,23 @@ private extension MessageListView {
|
||||
}
|
||||
|
||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||
guard privatePeer == nil else { return }
|
||||
let messages = publicChatModel.messages
|
||||
guard privatePeer == nil, let lastMsg = messages.last else { return }
|
||||
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
|
||||
guard let lastMsg = messages.last else {
|
||||
// Timeline emptied (e.g. /clear): nothing below to jump to.
|
||||
unseenCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
// If the newest message is from me, always scroll to bottom
|
||||
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
unseenCount += appendedCount
|
||||
return
|
||||
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
@@ -352,18 +554,23 @@ private extension MessageListView {
|
||||
}
|
||||
|
||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
||||
guard let peerID = privatePeer,
|
||||
let lastMsg = privateInboxModel.messages(for: peerID).last else {
|
||||
guard let peerID = privatePeer else { return }
|
||||
let messages = privateInboxModel.messages(for: peerID)
|
||||
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
|
||||
guard let lastMsg = messages.last else {
|
||||
// Timeline emptied (e.g. /clear): nothing below to jump to.
|
||||
unseenCount = 0
|
||||
return
|
||||
}
|
||||
let messages = privateInboxModel.messages(for: peerID)
|
||||
|
||||
// If the newest private message is from me, always scroll
|
||||
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
unseenCount += appendedCount
|
||||
return
|
||||
} else {
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
@@ -391,17 +598,27 @@ private extension MessageListView {
|
||||
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
|
||||
// When switching to a new geohash channel, scroll to the bottom
|
||||
guard privatePeer == nil else { return }
|
||||
// Invalidate the unseen baseline: the timeline is about to swap (or
|
||||
// already has — the ordering of this onChange vs the count onChange
|
||||
// is not guaranteed), so the next count observation re-baselines
|
||||
// instead of booking the cross-channel difference as "new".
|
||||
unseenCount = 0
|
||||
unseenBaselineKey = ""
|
||||
// Entering any public channel shows its latest messages: a channel
|
||||
// switch swaps the timeline wholesale, so the prior scroll offset is
|
||||
// meaningless. Landing at the bottom keeps isAtBottom honest (no
|
||||
// stale jump-to-latest pill) and matches standard chat behavior.
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey: String
|
||||
switch channel {
|
||||
case .mesh:
|
||||
break
|
||||
contextKey = "mesh"
|
||||
case .location(let ch):
|
||||
// Reset window size
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey = "geo:\(ch.geohash)"
|
||||
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
contextKey = "geo:\(ch.geohash)"
|
||||
}
|
||||
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +643,6 @@ private extension ChannelID {
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// #Preview {
|
||||
// MessageListView()
|
||||
//}
|
||||
// }
|
||||
|
||||
@@ -11,7 +11,10 @@ import AppKit
|
||||
struct MyQRView: View {
|
||||
let qrString: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
@ThemedPalette private var palette
|
||||
// Palette-tinted so the box follows the theme (green under matrix)
|
||||
// instead of a fixed gray band over the glass gradient.
|
||||
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
@@ -50,6 +53,7 @@ struct MyQRView: View {
|
||||
struct QRCodeImage: View {
|
||||
let data: String
|
||||
let size: CGFloat
|
||||
@ThemedPalette private var palette
|
||||
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
|
||||
.frame(width: size, height: size)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text(Strings.unavailable)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.gray)
|
||||
.foregroundColor(palette.secondary)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +112,7 @@ struct ImageWrapper: View {
|
||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@ThemedPalette private var palette
|
||||
var isActive: Bool = true
|
||||
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||
@State private var input = ""
|
||||
@@ -153,7 +158,7 @@ struct QRScanView: View {
|
||||
.bitchatFont(size: 14, weight: .medium)
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
.border(palette.secondary.opacity(0.4))
|
||||
Button(Strings.validate) {
|
||||
// Deduplicate: ignore if we just processed this exact QR
|
||||
guard input != lastValid else {
|
||||
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
final class PreviewView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -285,7 +290,7 @@ struct VerificationSheetView: View {
|
||||
|
||||
private var backgroundColor: Color { palette.background }
|
||||
private var accentColor: Color { palette.accent }
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
|
||||
.bitchatFont(size: 14, weight: .bold)
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
showingScanner = false
|
||||
isPresented = false
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
|
||||
self.loadFirstPlainText(from: providers) { text in
|
||||
if let t = text, !t.isEmpty {
|
||||
// Treat as URL if parseable http(s), else plain text
|
||||
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
||||
if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
|
||||
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
||||
} else {
|
||||
self.saveAndFinish(text: t)
|
||||
|
||||
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
|
||||
context.meshNicknamesByPeerID = [
|
||||
PeerID(str: "1111111111111111"): "alice",
|
||||
PeerID(str: "2222222222222222"): "bob",
|
||||
PeerID(str: "3333333333333333"): "me",
|
||||
PeerID(str: "3333333333333333"): "me"
|
||||
]
|
||||
|
||||
// Matching query: suggestions and range are published, index resets.
|
||||
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
|
||||
"aaaabbbbccccdddd": "carol",
|
||||
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
|
||||
// identity's public key hex ends in "2222".
|
||||
"ffffeeeeddddcccc2222": "me",
|
||||
"ffffeeeeddddcccc2222": "me"
|
||||
]
|
||||
|
||||
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
|
||||
|
||||
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
// Same message under both keys: the read copy must win over sent.
|
||||
context.privateChats[peerID] = [
|
||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
|
||||
makePrivateMessage(id: "m2", timestamp: t2),
|
||||
makePrivateMessage(id: "m2", timestamp: t2)
|
||||
]
|
||||
context.privateChats[stablePeerID] = [
|
||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
|
||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
|
||||
]
|
||||
|
||||
let merged = coordinator.getPrivateChatMessages(for: peerID)
|
||||
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
makePrivateMessage(id: "m1", senderPeerID: convKey),
|
||||
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
|
||||
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
|
||||
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
|
||||
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
|
||||
]
|
||||
|
||||
coordinator.markPrivateMessagesAsRead(from: convKey)
|
||||
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
)
|
||||
context.privateChats[peerID] = [
|
||||
makePrivateMessage(id: "in-1", senderPeerID: peerID),
|
||||
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
|
||||
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
|
||||
]
|
||||
|
||||
coordinator.markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
|
||||
peerID: currentPeer,
|
||||
noisePublicKey: Data(repeating: 0x01, count: 32),
|
||||
nickname: "alice"
|
||||
),
|
||||
)
|
||||
]
|
||||
context.unreadPrivateMessages = [
|
||||
currentPeer,
|
||||
staleShortPeer,
|
||||
geoDMWithMessages,
|
||||
geoDMWithoutMessages,
|
||||
noiseKeyWithMessages,
|
||||
noiseKeyWithMessages
|
||||
]
|
||||
context.privateChats = [
|
||||
geoDMWithMessages: [makeMessage(id: "geo-1")],
|
||||
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
|
||||
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
|
||||
]
|
||||
|
||||
coordinator.didUpdatePeerList([currentPeer])
|
||||
|
||||
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
||||
context.privateChats[convKey] = [
|
||||
makeIncomingMessage(id: "mine-1", sender: "me"),
|
||||
makeIncomingMessage(id: "mine-2", sender: "me"),
|
||||
makeIncomingMessage(id: "mine-2", sender: "me")
|
||||
]
|
||||
|
||||
coordinator.handleDelivered(
|
||||
|
||||
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
context.privateChats[peerID] = [
|
||||
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
|
||||
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
||||
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
|
||||
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
|
||||
]
|
||||
coordinator.didDisconnectFromPeer(peerID)
|
||||
await drainMainActorTasks()
|
||||
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
context.unreadPrivateMessages = [peerID]
|
||||
context.privateChats[peerID] = [
|
||||
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
|
||||
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
||||
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
|
||||
]
|
||||
|
||||
coordinator.didDisconnectFromPeer(peerID)
|
||||
|
||||
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
||||
})
|
||||
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
|
||||
// LocationChannelManager is a process-wide singleton: a suite
|
||||
// running in parallel (e.g. CommandProcessorTests) can flip the
|
||||
// selected channel mid-test, which reroutes or drops the event
|
||||
// permanently — no amount of waiting recovers it. Re-assert the
|
||||
// channel and redeliver on each poll: every channel switch clears
|
||||
// the processed-event set and the store dedups by message ID, so
|
||||
// redelivery is idempotent and interference heals on the next
|
||||
// poll while a genuine failure still times out.
|
||||
if LocationChannelManager.shared.selectedChannel != channel {
|
||||
LocationChannelManager.shared.select(channel)
|
||||
}
|
||||
if viewModel.activeChannel == channel {
|
||||
viewModel.handleNostrEvent(signed)
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@@ -1000,7 +1015,11 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
|
||||
// Media sends hop through Task.detached; the global executor is
|
||||
// shared with every parallel test worker, so a loaded runner can
|
||||
// exceed the 5s default. waitUntil returns as soon as the condition
|
||||
// holds, so passing runs never pay the longer timeout.
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
|
||||
@@ -1020,7 +1039,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didFail = await TestHelpers.waitUntil({
|
||||
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
|
||||
}, timeout: 5.0)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didFail)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
@@ -1036,7 +1055,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: sourceURL)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||
@@ -1057,7 +1076,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didNotify = await TestHelpers.waitUntil({
|
||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||
}, timeout: 5.0)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
|
||||
@@ -263,6 +263,59 @@ struct ChatViewModelCommandTests {
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_outputRoutesToOpenPrivateChat() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleCommand("/help")
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
|
||||
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_errorRoutesToOpenPrivateChat() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleCommand("/bogus")
|
||||
|
||||
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
|
||||
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
|
||||
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.handleCommand("/bogus")
|
||||
|
||||
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
let resolved = await TestHelpers.waitUntil({
|
||||
viewModel.getPeerIDForNickname("Alice") == peerID
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(resolved)
|
||||
|
||||
viewModel.handleCommand("/msg Alice")
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
|
||||
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Composer Tests
|
||||
|
||||
@@ -174,7 +174,7 @@ struct IntegrationTests {
|
||||
}
|
||||
|
||||
// Encrypted path: use NoiseSessionManager explicitly
|
||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||
let plaintext = Data("Encrypted message".utf8)
|
||||
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
||||
|
||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
@@ -206,7 +206,7 @@ struct IntegrationTests {
|
||||
|
||||
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
||||
// David tracks received messages
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { _ in
|
||||
completion()
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ struct IntegrationTests {
|
||||
}
|
||||
|
||||
do {
|
||||
let plaintext = "After restart success".data(using: .utf8)!
|
||||
let plaintext = Data("After restart success".utf8)
|
||||
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||
|
||||
@@ -121,4 +121,3 @@ final class TestNetworkHelper {
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,19 +52,19 @@ struct MimeTypeTests {
|
||||
@Test(arguments: [
|
||||
// === Image types ===
|
||||
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
|
||||
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
|
||||
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
|
||||
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
|
||||
|
||||
// === Audio types ===
|
||||
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
|
||||
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
|
||||
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
|
||||
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
|
||||
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
|
||||
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
|
||||
|
||||
// === Application types ===
|
||||
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
|
||||
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
|
||||
])
|
||||
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
|
||||
let data = Data(bytes)
|
||||
|
||||
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
|
||||
Data(),
|
||||
Data(repeating: 0x00, count: 32),
|
||||
Data([0x01] + Array(repeating: 0x00, count: 31)),
|
||||
Data(repeating: 0xFF, count: 32),
|
||||
Data(repeating: 0xFF, count: 32)
|
||||
]
|
||||
|
||||
for invalidKey in invalidKeys {
|
||||
|
||||
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
|
||||
@Test func basicEncryptionDecryption() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
let plaintext = "Hello, Bob!".data(using: .utf8)!
|
||||
let plaintext = Data("Hello, Bob!".utf8)
|
||||
|
||||
// Alice encrypts
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
// Alice -> Bob
|
||||
let aliceMessage = "Hello from Alice".data(using: .utf8)!
|
||||
let aliceMessage = Data("Hello from Alice".utf8)
|
||||
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
|
||||
let bobReceived = try bobSession.decrypt(aliceCiphertext)
|
||||
#expect(bobReceived == aliceMessage)
|
||||
|
||||
// Bob -> Alice
|
||||
let bobMessage = "Hello from Bob".data(using: .utf8)!
|
||||
let bobMessage = Data("Hello from Bob".utf8)
|
||||
let bobCiphertext = try bobSession.encrypt(bobMessage)
|
||||
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
|
||||
#expect(aliceReceived == bobMessage)
|
||||
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
|
||||
}
|
||||
|
||||
@Test func encryptionBeforeHandshake() {
|
||||
let plaintext = "test".data(using: .utf8)!
|
||||
let plaintext = Data("test".utf8)
|
||||
|
||||
#expect(throws: NoiseSessionError.notEstablished) {
|
||||
try aliceSession.encrypt(plaintext)
|
||||
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Encrypt with manager
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let plaintext = Data("Test message".utf8)
|
||||
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||
|
||||
// Decrypt with manager
|
||||
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
|
||||
@Test func tamperedCiphertextDetection() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
let plaintext = Data("Secret message".utf8)
|
||||
var ciphertext = try aliceSession.encrypt(plaintext)
|
||||
|
||||
// Tamper with ciphertext
|
||||
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
|
||||
@Test func replayPrevention() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let plaintext = Data("Test message".utf8)
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
|
||||
// First decryption should succeed
|
||||
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
|
||||
try performHandshake(initiator: aliceSession2, responder: bobSession2)
|
||||
|
||||
// Encrypt with session 1
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let plaintext = Data("Secret".utf8)
|
||||
let ciphertext1 = try aliceSession1.encrypt(plaintext)
|
||||
|
||||
// Should not be able to decrypt with session 2
|
||||
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Exchange some messages to establish nonce state
|
||||
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
|
||||
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
|
||||
_ = try bobManager.decrypt(message1, from: bobPeerID)
|
||||
|
||||
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
|
||||
let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID)
|
||||
_ = try aliceManager.decrypt(message2, from: alicePeerID)
|
||||
|
||||
// Simulate Bob restart by creating new manager with same key
|
||||
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
|
||||
// Should be able to exchange messages with new sessions
|
||||
let testMessage = "After restart".data(using: .utf8)!
|
||||
let testMessage = Data("After restart".utf8)
|
||||
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
|
||||
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
|
||||
#expect(decrypted == testMessage)
|
||||
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Exchange messages to advance nonces
|
||||
for i in 0..<5 {
|
||||
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
|
||||
let msg = try aliceSession.encrypt(Data("Message \(i)".utf8))
|
||||
_ = try bobSession.decrypt(msg)
|
||||
}
|
||||
|
||||
// Simulate desynchronization by encrypting but not decrypting
|
||||
for i in 0..<3 {
|
||||
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
|
||||
_ = try aliceSession.encrypt(Data("Lost message \(i)".utf8))
|
||||
}
|
||||
|
||||
// With per-packet nonce carried, decryption should not throw here
|
||||
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
|
||||
let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8))
|
||||
#expect(throws: Never.self) {
|
||||
try bobSession.decrypt(desyncMessage)
|
||||
}
|
||||
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
|
||||
|
||||
let messageCount = 100
|
||||
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
|
||||
{ completion in
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
|
||||
var encryptedMessages: [Int: Data] = [:]
|
||||
// Encrypt messages sequentially to avoid nonce races in manager
|
||||
for i in 0..<messageCount {
|
||||
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
||||
let plaintext = Data("Concurrent message \(i)".utf8)
|
||||
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||
encryptedMessages[i] = encrypted
|
||||
}
|
||||
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
|
||||
return
|
||||
}
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
||||
let expected = Data("Concurrent message \(i)".utf8)
|
||||
#expect(decrypted == expected)
|
||||
completion()
|
||||
} catch {
|
||||
@@ -485,7 +484,7 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Create a corrupted message
|
||||
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
|
||||
var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID)
|
||||
encrypted[10] ^= 0xFF // Corrupt the data
|
||||
|
||||
// Decryption should fail
|
||||
@@ -516,7 +515,7 @@ struct NoiseProtocolTests {
|
||||
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
|
||||
|
||||
// Exchange messages to verify sessions work
|
||||
let testMessage = "Session works".data(using: .utf8)!
|
||||
let testMessage = Data("Session works".utf8)
|
||||
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
#expect(decrypted == testMessage)
|
||||
@@ -539,7 +538,7 @@ struct NoiseProtocolTests {
|
||||
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
|
||||
|
||||
// Verify new sessions work
|
||||
let testMessage2 = "New session works".data(using: .utf8)!
|
||||
let testMessage2 = Data("New session works".utf8)
|
||||
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
|
||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
|
||||
#expect(decrypted2 == testMessage2)
|
||||
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Exchange messages normally
|
||||
for i in 0..<5 {
|
||||
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||
let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID)
|
||||
_ = try bobManager.decrypt(msg, from: bobPeerID)
|
||||
}
|
||||
|
||||
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
|
||||
for i in 0..<3 {
|
||||
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||
_ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID)
|
||||
}
|
||||
|
||||
// With nonce carried in packet, decryption should not throw here
|
||||
let desyncMessage = try aliceManager.encrypt(
|
||||
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
||||
Data("This now succeeds".utf8), for: alicePeerID)
|
||||
#expect(throws: Never.self) {
|
||||
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
||||
}
|
||||
@@ -587,7 +586,7 @@ struct NoiseProtocolTests {
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
|
||||
// Verify communication works again
|
||||
let testResynced = "Resynced".data(using: .utf8)!
|
||||
let testResynced = Data("Resynced".utf8)
|
||||
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
|
||||
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
|
||||
#expect(decryptedResync == testResynced)
|
||||
@@ -900,20 +899,20 @@ struct NoiseProtocolTests {
|
||||
#expect(trackingKeychain.secureClearDataCallCount > 0)
|
||||
|
||||
// Test encryption from Alice to Bob
|
||||
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
|
||||
let plaintext1 = Data("Hello from Alice after secureClear!".utf8)
|
||||
let ciphertext1 = try alice.encrypt(plaintext1)
|
||||
let decrypted1 = try bob.decrypt(ciphertext1)
|
||||
#expect(decrypted1 == plaintext1)
|
||||
|
||||
// Test encryption from Bob to Alice
|
||||
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
|
||||
let plaintext2 = Data("Hello from Bob after secureClear!".utf8)
|
||||
let ciphertext2 = try bob.encrypt(plaintext2)
|
||||
let decrypted2 = try alice.decrypt(ciphertext2)
|
||||
#expect(decrypted2 == plaintext2)
|
||||
|
||||
// Test multiple messages to verify cipher state is correct
|
||||
for i in 1...10 {
|
||||
let msg = "Message \(i) from Alice".data(using: .utf8)!
|
||||
let msg = Data("Message \(i) from Alice".utf8)
|
||||
let cipher = try alice.encrypt(msg)
|
||||
let dec = try bob.decrypt(cipher)
|
||||
#expect(dec == msg)
|
||||
|
||||
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
|
||||
("checking in from the harbor #bitchat #mesh", nil),
|
||||
("@bob#0042 ping me when you get this", ["bob#0042"]),
|
||||
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
|
||||
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
|
||||
]
|
||||
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
|
||||
(0..<batchSize).map { i in
|
||||
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
"anyone near the station?",
|
||||
"@bob#0042 are you on mesh too?",
|
||||
"check this out https://example.com/p/123 #bitchat",
|
||||
"teleport check, who's local?",
|
||||
"teleport check, who's local?"
|
||||
]
|
||||
return try (0..<count).map { i in
|
||||
try NostrProtocol.createEphemeralGeohashEvent(
|
||||
|
||||
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
|
||||
struct ProtocolContractTests {
|
||||
@Test
|
||||
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
||||
#expect(CommandInfo.message.id == "dm")
|
||||
#expect(CommandInfo.message.alias == "/dm")
|
||||
// Aliases must match what CommandProcessor actually accepts —
|
||||
// the suggestion panel is the only command-discovery surface.
|
||||
#expect(CommandInfo.message.id == "msg")
|
||||
#expect(CommandInfo.message.alias == "/msg")
|
||||
#expect(CommandInfo.message.placeholder != nil)
|
||||
#expect(CommandInfo.clear.placeholder == nil)
|
||||
#expect(CommandInfo.favorite.description.isEmpty == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
|
||||
// Favorites are rejected by the processor in geohash contexts, so
|
||||
// they are suggested only in mesh.
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests {
|
||||
|
||||
#expect(service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_persistsByFingerprintAndToggles() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000EE")
|
||||
let fingerprint = "fp-target"
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
|
||||
// Blocking resolves and persists by the peer's fingerprint.
|
||||
let resolved = service.setBlocked(peerID, blocked: true)
|
||||
#expect(resolved == fingerprint)
|
||||
#expect(identity.isBlocked(fingerprint: fingerprint))
|
||||
#expect(service.isBlocked(peerID))
|
||||
|
||||
// Unblocking clears it against the same identity.
|
||||
let unresolved = service.setBlocked(peerID, blocked: false)
|
||||
#expect(unresolved == fingerprint)
|
||||
#expect(!identity.isBlocked(fingerprint: fingerprint))
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_unknownIdentityReturnsNil() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
// No fingerprint resolvable for this peer (offline & unknown).
|
||||
let peerID = PeerID(str: "00000000000000FF")
|
||||
|
||||
#expect(service.setBlocked(peerID, blocked: true) == nil)
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
@@ -12,6 +12,11 @@ import Foundation
|
||||
struct TestConstants {
|
||||
static let defaultTimeout: TimeInterval = 5.0
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
/// For positive waits on work that hops through `Task.detached` or
|
||||
/// background queues: those contend with every parallel test worker for
|
||||
/// the global executor, so a loaded CI runner can exceed
|
||||
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
|
||||
/// so passing runs never pay the longer timeout.
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
|
||||
@@ -54,13 +54,13 @@ final class TestHelpers {
|
||||
type: UInt8 = 0x01,
|
||||
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||
recipientID: PeerID? = nil,
|
||||
payload: Data = "test payload".data(using: .utf8)!,
|
||||
payload: Data = Data("test payload".utf8),
|
||||
signature: Data? = nil,
|
||||
ttl: UInt8 = 3
|
||||
) -> BitchatPacket {
|
||||
return BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID.id.data(using: .utf8)!,
|
||||
senderID: Data(senderID.id.utf8),
|
||||
recipientID: recipientID?.id.data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
|
||||
@@ -556,11 +556,19 @@ struct ViewSmokeTests {
|
||||
@Test
|
||||
func voiceAndMediaViews_renderAndWarmCaches() async throws {
|
||||
let audioURL = try makeTemporaryAudioURL()
|
||||
// Probed directly below. Deliberately a separate file from `audioURL`:
|
||||
// `WaveformCache.shared` is process-wide and the mounted
|
||||
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
|
||||
// width concurrently, so asserting an exact bin count for that URL
|
||||
// races with the view's own cache write.
|
||||
let waveformProbeURL = try makeTemporaryAudioURL()
|
||||
let imageURL = try makeTemporaryImageURL()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: audioURL)
|
||||
try? FileManager.default.removeItem(at: waveformProbeURL)
|
||||
try? FileManager.default.removeItem(at: imageURL)
|
||||
WaveformCache.shared.purge(url: audioURL)
|
||||
WaveformCache.shared.purge(url: waveformProbeURL)
|
||||
}
|
||||
|
||||
let waveformView = WaveformView(
|
||||
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
|
||||
_ = mount(voiceNoteView)
|
||||
|
||||
let bins = await withCheckedContinuation { continuation in
|
||||
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
|
||||
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
|
||||
continuation.resume(returning: values)
|
||||
}
|
||||
}
|
||||
playback.loadDuration()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
// loadDuration hops through a background queue and back to main; poll
|
||||
// instead of a fixed sleep so a loaded runner can't outlast the wait.
|
||||
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
|
||||
playback.seek(to: 1.25)
|
||||
playback.stop()
|
||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
|
||||
#expect(bins.count == 16)
|
||||
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
||||
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
|
||||
#expect(playback.duration > 0)
|
||||
#expect(playback.progress == 0)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import struct Foundation.Data
|
||||
struct XChaCha20Poly1305CompatTests {
|
||||
|
||||
@Test func sealAndOpenRoundtrip() throws {
|
||||
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
|
||||
let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealAndOpenWithAAD() throws {
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
let plaintext = Data("Secret message".utf8)
|
||||
let key = Data(repeating: 0xAB, count: 32)
|
||||
let nonce = Data(repeating: 0xCD, count: 24)
|
||||
let aad = "additional authenticated data".data(using: .utf8)!
|
||||
let aad = Data("additional authenticated data".utf8)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
|
||||
let decrypted = try XChaCha20Poly1305Compat.open(
|
||||
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
|
||||
let plaintext = "Same plaintext".data(using: .utf8)!
|
||||
let plaintext = Data("Same plaintext".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce1 = Data(repeating: 0x01, count: 24)
|
||||
let nonce2 = Data(repeating: 0x02, count: 24)
|
||||
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let shortKey = Data(repeating: 0x42, count: 16)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let longKey = Data(repeating: 0x42, count: 64)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let emptyKey = Data()
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let shortNonce = Data(repeating: 0x24, count: 12)
|
||||
|
||||
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let longNonce = Data(repeating: 0x24, count: 32)
|
||||
|
||||
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let emptyNonce = Data()
|
||||
|
||||
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func openFailsWithWrongKey() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let plaintext = Data("Secret".utf8)
|
||||
let correctKey = Data(repeating: 0x42, count: 32)
|
||||
let wrongKey = Data(repeating: 0x43, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func openFailsWithTamperedCiphertext() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let plaintext = Data("Secret".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ let package = Package(
|
||||
name: "Tor", // Keep name "Tor" for drop-in compatibility
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Tor",
|
||||
targets: ["Tor"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../BitLogger"),
|
||||
.package(path: "../BitLogger")
|
||||
],
|
||||
targets: [
|
||||
// Main Swift target
|
||||
@@ -22,19 +22,19 @@ let package = Package(
|
||||
name: "Tor",
|
||||
dependencies: [
|
||||
"arti",
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "BitLogger", package: "BitLogger")
|
||||
],
|
||||
path: "Sources",
|
||||
exclude: ["C"],
|
||||
sources: [
|
||||
"TorManager.swift",
|
||||
"TorURLSession.swift",
|
||||
"TorNotifications.swift",
|
||||
"TorNotifications.swift"
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("resolv"),
|
||||
.linkedLibrary("z"),
|
||||
.linkedLibrary("sqlite3"),
|
||||
.linkedLibrary("sqlite3")
|
||||
]
|
||||
),
|
||||
// Binary framework containing the Rust static library.
|
||||
@@ -42,6 +42,6 @@ let package = Package(
|
||||
.binaryTarget(
|
||||
name: "arti",
|
||||
path: "Frameworks/arti.xcframework"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "BitFoundation",
|
||||
dependencies: [
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "BitLogger", package: "BitLogger")
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
|
||||
@@ -46,7 +46,8 @@ struct BinaryProtocolTests {
|
||||
// Verify recipient
|
||||
#expect(decodedPacket.recipientID != nil)
|
||||
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
||||
// TODO: Check if this is intended that the decoding only gets the first 8
|
||||
// Recipient IDs are a fixed 8-byte wire field: encode pads or truncates
|
||||
// to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive.
|
||||
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
|
||||
}
|
||||
|
||||
@@ -294,7 +295,7 @@ struct BinaryProtocolTests {
|
||||
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||
func payloadCompression() throws {
|
||||
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
||||
let largePayload = repeatedString.data(using: .utf8)!
|
||||
let largePayload = Data(repeatedString.utf8)
|
||||
|
||||
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
||||
|
||||
@@ -314,7 +315,7 @@ struct BinaryProtocolTests {
|
||||
|
||||
@Test("Small payloads should not be compressed")
|
||||
func smallPayloadNoCompression() throws {
|
||||
let smallPayload = "Hi".data(using: .utf8)!
|
||||
let smallPayload = Data("Hi".utf8)
|
||||
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
||||
@@ -362,7 +363,7 @@ struct BinaryProtocolTests {
|
||||
var encodedSizes = Set<Int>()
|
||||
|
||||
for payload in payloads {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
|
||||
let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8))
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||
|
||||
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
||||
|
||||
@@ -54,13 +54,13 @@ final class TestHelpers {
|
||||
type: UInt8 = 0x01,
|
||||
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||
recipientID: PeerID? = nil,
|
||||
payload: Data = "test payload".data(using: .utf8)!,
|
||||
payload: Data = Data("test payload".utf8),
|
||||
signature: Data? = nil,
|
||||
ttl: UInt8 = 3
|
||||
) -> BitchatPacket {
|
||||
return BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID.id.data(using: .utf8)!,
|
||||
senderID: Data(senderID.id.utf8),
|
||||
recipientID: recipientID?.id.data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
|
||||
@@ -11,16 +11,34 @@
|
||||
# never runner variance. Raise floors deliberately after intentional
|
||||
# improvements; never tune them to chase noise.
|
||||
#
|
||||
# Retry-on-noise: even generous floors can be dipped under by a saturated
|
||||
# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub
|
||||
# macOS runner). When a benchmark lands below its floor, the gate re-runs the
|
||||
# benchmark suite — appending to the same PERF log — and keeps each
|
||||
# benchmark's BEST observed value across attempts. Runner noise clears on a
|
||||
# retry; a real algorithmic regression stays below floor on every attempt and
|
||||
# still fails. Floors themselves are never lowered by this mechanism.
|
||||
#
|
||||
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
|
||||
#
|
||||
# Environment:
|
||||
# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3)
|
||||
# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor
|
||||
# result (default: swift test --quiet
|
||||
# --filter PerformanceBaselineTests). The
|
||||
# command runs with BITCHAT_PERF_LOG pointed at
|
||||
# the output file so new PERF lines append.
|
||||
#
|
||||
# Skips gracefully (exit 0) when:
|
||||
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
|
||||
# - the output contains no PERF lines (e.g. package-only matrix entries).
|
||||
#
|
||||
# Fails (exit 1) when:
|
||||
# - any benchmark reports throughput below its floor, or
|
||||
# - PERF lines are present but a floored benchmark is missing
|
||||
# (a silently-dropped benchmark must be an explicit floors-file change).
|
||||
# Fails when:
|
||||
# - any benchmark reports throughput below its floor on every attempt
|
||||
# (exit 1), or
|
||||
# - PERF lines are present but a floored benchmark is missing — a
|
||||
# silently-dropped benchmark must be an explicit floors-file change and
|
||||
# is not retried (exit 3).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -31,6 +49,8 @@ fi
|
||||
|
||||
OUTPUT_FILE="$1"
|
||||
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
|
||||
MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}"
|
||||
REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}"
|
||||
|
||||
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
|
||||
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
|
||||
@@ -52,7 +72,17 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
||||
# Absolute path so re-measurement appends to the same file regardless of the
|
||||
# working directory the test process runs in.
|
||||
case "$OUTPUT_FILE" in
|
||||
/*) ;;
|
||||
*) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;;
|
||||
esac
|
||||
|
||||
# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs
|
||||
# regression undecided), 3 = floored benchmark missing (not retryable).
|
||||
check_floors() {
|
||||
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -72,15 +102,20 @@ with open(output_file, errors="replace") as f:
|
||||
for line in f:
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
# Keep the last reported value if a benchmark prints twice.
|
||||
measured[m.group(1)] = (float(m.group(2)), m.group(3))
|
||||
# Keep the BEST reported value: measurement retries append to the
|
||||
# same log, and a healthy benchmark only needs to clear its floor
|
||||
# once — a real regression never does.
|
||||
name, value, unit = m.group(1), float(m.group(2)), m.group(3)
|
||||
if name not in measured or value > measured[name][0]:
|
||||
measured[name] = (value, unit)
|
||||
|
||||
failures = []
|
||||
below_floor = []
|
||||
missing = []
|
||||
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
|
||||
for name in sorted(set(floors) | set(measured)):
|
||||
floor = floors.get(name)
|
||||
if name not in measured:
|
||||
failures.append(
|
||||
missing.append(
|
||||
f" MISSING {name}: floored benchmark reported no PERF line "
|
||||
f"(removed/renamed? update perf-floors.json in the same change)")
|
||||
continue
|
||||
@@ -92,13 +127,18 @@ for name in sorted(set(floors) | set(measured)):
|
||||
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
|
||||
print(line)
|
||||
if value < floor:
|
||||
failures.append(
|
||||
below_floor.append(
|
||||
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
|
||||
f"({value / floor * 100:.0f}% of floor)")
|
||||
|
||||
if failures:
|
||||
print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
|
||||
print("\n".join(failures))
|
||||
if missing:
|
||||
print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:")
|
||||
print("\n".join(missing + below_floor))
|
||||
sys.exit(3)
|
||||
|
||||
if below_floor:
|
||||
print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:")
|
||||
print("\n".join(below_floor))
|
||||
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
|
||||
print("algorithmic regression, not runner noise. If the change is intentional,")
|
||||
print("update bitchatTests/Performance/perf-floors.json deliberately.")
|
||||
@@ -106,3 +146,36 @@ if failures:
|
||||
|
||||
print("perf-floors: all benchmarks at or above their floors.")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
attempt=1
|
||||
while true; do
|
||||
gate_status=0
|
||||
check_floors || gate_status=$?
|
||||
|
||||
case "$gate_status" in
|
||||
0)
|
||||
exit 0
|
||||
;;
|
||||
1)
|
||||
# Below floor: retry to separate runner noise from regression.
|
||||
;;
|
||||
*)
|
||||
# Missing benchmark or parse/setup error: re-measuring can't help.
|
||||
exit "$gate_status"
|
||||
;;
|
||||
esac
|
||||
|
||||
if (( attempt >= MAX_ATTEMPTS )); then
|
||||
echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression."
|
||||
# Word splitting of REMEASURE_CMD is deliberate: it is a command line.
|
||||
if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then
|
||||
echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user