diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 7e8c5b06..c82ade66 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -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 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..e35a75d0 --- /dev/null +++ b/.swiftlint.yml @@ -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 diff --git a/Package.swift b/Package.swift index 3f6e6b8f..7d447630 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index f012edd3..461449cc 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -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 ` 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) } diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 71871984..2a465948 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -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) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 4f2e4199..e606506f 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -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 } diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 29f036c6..ad83962f 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -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 diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index be1bba06..7301e613 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -5015,6 +5015,162 @@ } } }, + "app_info.legend.blocked" : { + "comment" : "Legend entry for the nosign glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + } + } + }, + "app_info.legend.encrypted" : { + "comment" : "Legend entry for the lock glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "end-to-end encrypted session" + } + } + } + }, + "app_info.legend.encryption_failed" : { + "comment" : "Legend entry for the failed-encryption lock-slash glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed — messages not secured" + } + } + } + }, + "app_info.legend.favorite" : { + "comment" : "Legend entry for the star glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite — enables offline messages via nostr when mutual" + } + } + } + }, + "app_info.legend.location_nearby" : { + "comment" : "Legend entry for the map pin glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "physically in this location channel's area" + } + } + } + }, + "app_info.legend.mesh_connected" : { + "comment" : "Legend entry for the antenna glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected directly over bluetooth" + } + } + } + }, + "app_info.legend.mesh_relayed" : { + "comment" : "Legend entry for the relayed-mesh glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable through the mesh, relayed by others" + } + } + } + }, + "app_info.legend.nostr" : { + "comment" : "Legend entry for the globe glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable over the internet (nostr) — mutual favorites only" + } + } + } + }, + "app_info.legend.offline" : { + "comment" : "Legend entry for the offline person glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline — not currently reachable" + } + } + } + }, + "app_info.legend.teleported" : { + "comment" : "Legend entry for the teleported glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported — joined the channel from somewhere else" + } + } + } + }, + "app_info.legend.title" : { + "comment" : "Section header for the symbols legend in app info", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "symbols" + } + } + } + }, + "app_info.legend.unread" : { + "comment" : "Legend entry for the envelope glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unread private messages" + } + } + } + }, + "app_info.legend.verified" : { + "comment" : "Legend entry for the verified seal glyph", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "identity verified" + } + } + } + }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { @@ -8416,6 +8572,42 @@ } } }, + "content.accessibility.app_info_hint" : { + "comment" : "Accessibility hint on the bitchat/ logo explaining a tap opens app info", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "shows app info" + } + } + } + }, + "content.accessibility.attach_photo" : { + "comment" : "Accessibility label for the photo attachment button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "attach photo" + } + } + } + }, + "content.accessibility.attach_photo_hint" : { + "comment" : "Accessibility hint explaining the attachment button opens the photo library", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the photo library; use the take photo action for the camera" + } + } + } + }, "content.accessibility.available_nostr" : { "extractionState" : "manual", "localizations" : { @@ -8774,6 +8966,18 @@ } } }, + "content.accessibility.choose_photo" : { + "comment" : "Accessibility label for the macOS photo picker button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "choose photo" + } + } + } + }, "content.accessibility.connected_mesh" : { "extractionState" : "manual", "localizations" : { @@ -8953,6 +9157,18 @@ } } }, + "content.accessibility.delivery_detail_hint" : { + "comment" : "Accessibility hint for the delivery status glyph explaining a tap reveals details", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to show delivery details" + } + } + } + }, "content.accessibility.encryption_status" : { "extractionState" : "manual", "localizations" : { @@ -9132,6 +9348,18 @@ } } }, + "content.accessibility.jump_to_latest" : { + "comment" : "Accessibility label for the jump to latest messages button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "jump to latest messages" + } + } + } + }, "content.accessibility.location_channels" : { "extractionState" : "manual", "localizations" : { @@ -9669,6 +9897,30 @@ } } }, + "content.accessibility.peers_connected" : { + "comment" : "Accessibility value when peers are reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected" + } + } + } + }, + "content.accessibility.peers_none" : { + "comment" : "Accessibility value when no peers are reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no one reachable" + } + } + } + }, "content.accessibility.people_count" : { "extractionState" : "manual", "localizations" : { @@ -10590,6 +10842,42 @@ } } }, + "content.accessibility.record_voice_hint" : { + "comment" : "Accessibility hint explaining double-tap toggles voice recording", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "double-tap to start recording, double-tap again to send" + } + } + } + }, + "content.accessibility.record_voice_note" : { + "comment" : "Accessibility label for the voice note button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "record voice note" + } + } + } + }, + "content.accessibility.recording" : { + "comment" : "Accessibility value announced while a voice note is recording", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording" + } + } + } + }, "content.accessibility.remove_favorite" : { "extractionState" : "manual", "localizations" : { @@ -11306,6 +11594,18 @@ } } }, + "content.accessibility.take_photo" : { + "comment" : "Accessibility action name for taking a photo with the camera", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "take photo with camera" + } + } + } + }, "content.accessibility.toggle_bookmark" : { "extractionState" : "manual", "localizations" : { @@ -11664,6 +11964,18 @@ } } }, + "content.accessibility.verification" : { + "comment" : "Accessibility label for the verification QR button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "verify encryption" + } + } + } + }, "content.accessibility.view_fingerprint_hint" : { "extractionState" : "manual", "localizations" : { @@ -12559,6 +12871,18 @@ } } }, + "content.actions.resend" : { + "comment" : "Context menu action that resends a failed private message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "resend" + } + } + } + }, "content.actions.slap" : { "extractionState" : "manual", "localizations" : { @@ -14170,6 +14494,30 @@ } } }, + "content.clear.confirm_action" : { + "comment" : "Destructive confirmation button that clears the current chat", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear chat" + } + } + } + }, + "content.clear.confirm_title" : { + "comment" : "Title of the confirmation dialog shown before clearing the current chat", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear this chat?" + } + } + } + }, "content.commands.block" : { "extractionState" : "manual", "localizations" : { @@ -14707,6 +15055,18 @@ } } }, + "content.commands.help" : { + "comment" : "Description of the /help command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show available commands" + } + } + } + }, "content.commands.hug" : { "extractionState" : "manual", "localizations" : { @@ -16676,6 +17036,54 @@ } } }, + "content.delivery.reason.not_delivered" : { + "comment" : "Failure reason shown when the router gave up delivering a message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "not delivered" + } + } + } + }, + "content.delivery.reason.encryption_failed" : { + "comment" : "Failure reason shown when a message could not be encrypted for the peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed" + } + } + } + }, + "content.delivery.reason.voice_too_large" : { + "comment" : "Failure reason shown when a voice note exceeds the size limit", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note too large" + } + } + } + }, + "content.delivery.reason.voice_send_failed" : { + "comment" : "Failure reason shown when a voice note could not be sent", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note failed to send" + } + } + } + }, "content.delivery.reason.self" : { "extractionState" : "manual", "localizations" : { @@ -17392,6 +17800,78 @@ } } }, + "content.delivery.sending" : { + "comment" : "Delivery status description while a private message is being sent", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending..." + } + } + } + }, + "content.delivery.sent" : { + "comment" : "Delivery status description for a sent but not yet confirmed private message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent — no delivery confirmation yet" + } + } + } + }, + "content.empty.location_intro" : { + "comment" : "First line of an empty geohash timeline naming the channel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're in #%@ — a public location channel over the internet" + } + } + } + }, + "content.empty.mesh_intro" : { + "comment" : "First line of the empty mesh timeline explaining what the mesh channel is", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're on #mesh — reaches people within bluetooth range" + } + } + } + }, + "content.empty.mesh_waiting" : { + "comment" : "Second line of the empty mesh timeline saying no peers are in range yet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nobody in range yet... messages appear here" + } + } + } + }, + "content.empty.switch_hint" : { + "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap the channel name above to switch · tap bitchat/ for help" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -17750,181 +18230,62 @@ } } }, - "content.input.message_placeholder" : { + "content.input.placeholder.location" : { + "comment" : "Composer placeholder for a public geohash channel, naming it", "extractionState" : "manual", "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اكتب رسالة..." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটি বার্তা লিখুন..." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "nachricht eingeben..." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "type a message..." + "value" : "message #%@ — public" } - }, - "es" : { + } + } + }, + "content.input.placeholder.mesh" : { + "comment" : "Composer placeholder for the public mesh channel", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "escribe un mensaje..." + "value" : "message #mesh — public, nearby" } - }, - "fil" : { + } + } + }, + "content.input.placeholder.private" : { + "comment" : "Composer placeholder inside a private chat, naming the conversation partner", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "mag-type ng mensahe..." + "value" : "message %@ — private" } - }, - "fr" : { + } + } + }, + "content.private.caption" : { + "comment" : "Caption above the private chat composer before encryption is established", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "écris un message..." + "value" : "private conversation" } - }, - "he" : { + } + } + }, + "content.private.caption_encrypted" : { + "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "כתוב הודעה..." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "संदेश लिखें..." - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "ketik pesan..." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "scrivi un messaggio..." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メッセージを入力..." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "메시지를 입력하세요..." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "ketik pesan..." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "सन्देश टाइप गर..." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "typ een bericht..." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "wpisz wiadomość..." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "escreve uma mensagem..." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "digite uma mensagem..." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "напиши сообщение..." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "skriv ett meddelande..." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒரு செய்தியைத் தட்டச்சு செய்க..." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "พิมพ์ข้อความ..." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bir mesaj yazın..." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "напиши повідомлення..." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "پیغام ٹائپ کریں..." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "nhập tin nhắn..." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "输入消息..." - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "輸入訊息..." + "value" : "private · end-to-end encrypted" } } } @@ -18108,6 +18469,18 @@ } } }, + "content.jump.new_count" : { + "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + } + } + }, "content.location.enable" : { "extractionState" : "manual", "localizations" : { @@ -23657,6 +24030,42 @@ } } }, + "geohash_people.state.nearby" : { + "comment" : "State label for someone physically in the location channel's area", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "in this area" + } + } + } + }, + "geohash_people.state.teleported" : { + "comment" : "State label for someone who joined the location channel from elsewhere", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported from elsewhere" + } + } + } + }, + "geohash_people.state.you" : { + "comment" : "State label marking your own row in the people list", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you" + } + } + } + }, "geohash_people.tooltip.blocked" : { "extractionState" : "manual", "localizations" : { @@ -24015,6 +24424,42 @@ } } }, + "location_channels.accessibility.add_bookmark" : { + "comment" : "Accessibility action name for bookmarking a channel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bookmark channel" + } + } + } + }, + "location_channels.accessibility.remove_bookmark" : { + "comment" : "Accessibility action name for removing a channel bookmark", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove bookmark" + } + } + } + }, + "location_channels.accessibility.switch_hint" : { + "comment" : "Accessibility hint on a channel row explaining activation switches to it", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "switches to this channel" + } + } + } + }, "location_channels.action.open_settings" : { "extractionState" : "manual", "localizations" : { @@ -31227,6 +31672,258 @@ } } }, + "media.accessibility.cancel_send" : { + "comment" : "Accessibility label for the cancel button on an in-flight media send", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancel sending" + } + } + } + }, + "media.image.accessibility.hidden" : { + "comment" : "Accessibility label for a blurred incoming image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hidden image" + } + } + } + }, + "media.image.accessibility.hint.open" : { + "comment" : "Accessibility hint for a revealed image; activating it opens the image full screen", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the image full screen" + } + } + } + }, + "media.image.accessibility.hint.reveal" : { + "comment" : "Accessibility hint for a blurred image; activating it reveals the image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveals the image" + } + } + } + }, + "media.image.accessibility.revealed" : { + "comment" : "Accessibility label for a revealed image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image" + } + } + } + }, + "media.image.accessibility.sending" : { + "comment" : "Accessibility label for an image that is still sending", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending image" + } + } + } + }, + "media.image.action.delete" : { + "comment" : "Context menu action that deletes a received image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete image" + } + } + } + }, + "media.image.action.hide" : { + "comment" : "Context menu action that re-blurs a revealed image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hide image" + } + } + } + }, + "media.image.action.open" : { + "comment" : "Context menu action that opens an image full screen", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open image" + } + } + } + }, + "media.image.action.reveal" : { + "comment" : "Context menu action that reveals a blurred image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveal image" + } + } + } + }, + "media.image.delete_confirm_message" : { + "comment" : "Body of the confirmation dialog before deleting a received image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this cannot be undone — the sender may not be in range to send it again." + } + } + } + }, + "media.image.delete_confirm_title" : { + "comment" : "Title of the confirmation dialog before deleting a received image", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete this image?" + } + } + } + }, + "media.image.tap_to_reveal" : { + "comment" : "Caption on a blurred incoming image inviting a tap to reveal it", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to reveal" + } + } + } + }, + "media.voice.accessibility.pause" : { + "comment" : "Accessibility label for pausing voice note playback", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pause voice note" + } + } + } + }, + "media.voice.accessibility.play" : { + "comment" : "Accessibility label for playing a voice note", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "play voice note" + } + } + } + }, + "mesh_peers.accessibility.open_dm_hint" : { + "comment" : "Accessibility hint on a peer row explaining activation opens a private chat", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens a private chat" + } + } + } + }, + "mesh_peers.action.fingerprint" : { + "comment" : "Context menu action that shows a peer's fingerprint/verification screen", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show fingerprint" + } + } + } + }, + "mesh_peers.state.blocked" : { + "comment" : "State label for a blocked peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + } + } + }, + "mesh_peers.state.favorite" : { + "comment" : "State label for a favorited peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite" + } + } + } + }, + "mesh_peers.state.offline" : { + "comment" : "State label for a peer that is not currently reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline" + } + } + } + }, + "mesh_peers.state.unread" : { + "comment" : "State label for a peer with unread private messages", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "new messages" + } + } + } + }, "mesh_peers.tooltip.new_messages" : { "extractionState" : "manual", "localizations" : { @@ -33554,6 +34251,54 @@ } } }, + "system.mesh.block_failed" : { + "comment" : "System message shown when a mesh peer cannot be blocked", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot block %@: not found or unable to verify identity" + } + } + } + }, + "system.mesh.blocked" : { + "comment" : "System message shown when a mesh peer is blocked", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked %@. you will no longer receive messages from them" + } + } + } + }, + "system.mesh.unblock_failed" : { + "comment" : "System message shown when a mesh peer cannot be unblocked", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot unblock %@: not found" + } + } + } + }, + "system.mesh.unblocked" : { + "comment" : "System message shown when a mesh peer is unblocked", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unblocked %@" + } + } + } + }, "system.tor.dev_bypass" : { "extractionState" : "manual", "localizations" : { @@ -36776,6 +37521,18 @@ } } } + }, + "media.image.accessibility.unavailable" : { + "comment" : "Accessibility label for an image whose file could not be loaded", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image unavailable" + } + } + } } }, "version" : "1.1" diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 0bf25537..1f8245be 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -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] } } diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index f68caa8d..d59a5200 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -648,7 +648,7 @@ private extension NostrProtocol { let derivedKey = HKDF.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) } diff --git a/bitchat/Nostr/XChaCha20Poly1305Compat.swift b/bitchat/Nostr/XChaCha20Poly1305Compat.swift index 13794508..006cf899 100644 --- a/bitchat/Nostr/XChaCha20Poly1305Compat.swift +++ b/bitchat/Nostr/XChaCha20Poly1305Compat.swift @@ -132,4 +132,3 @@ private extension Data { replaceSubrange(offset..<(offset+4), with: bytes) } } - diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index b7c0d5b6..cbe1c55f 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case .city: return 5 case .province: return 4 case .region: return 2 - } + } } var displayName: String { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 227e003f..45f2fd10 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -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) { diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index e9e81654..680c20f8 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -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 { diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index e99c1a3f..05c7c288 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -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) diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index d2a3fea0..8a99d9ec 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -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 diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 380593fb..35c96547 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -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 } diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index d51efa86..091f4289 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -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 { diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 3d180c68..82a51f3f 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -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 { diff --git a/bitchat/Utils/PeerDisplayNameResolver.swift b/bitchat/Utils/PeerDisplayNameResolver.swift index 77a89ec5..a92953d3 100644 --- a/bitchat/Utils/PeerDisplayNameResolver.swift +++ b/bitchat/Utils/PeerDisplayNameResolver.swift @@ -27,4 +27,3 @@ struct PeerDisplayNameResolver { return result } } - diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index ce51abe9..bf0009da 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -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")) } } } diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 0b52baa6..37ac4a50 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -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) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c7771204..803fec7f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } + // Mesh (Noise identity) block helpers. Unlike the `/block ` + // 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 diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 66d9c8f5..8897d7c8 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -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 ) } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index cce9499e..152c09df 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -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) diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index 786a3caf..cc4f9609 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -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[.. 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)) } } } diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index 605f7b47..e01887d0 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -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) } diff --git a/bitchat/Views/Components/SheetCloseButton.swift b/bitchat/Views/Components/SheetCloseButton.swift new file mode 100644 index 00000000..e2891e35 --- /dev/null +++ b/bitchat/Views/Components/SheetCloseButton.swift @@ -0,0 +1,29 @@ +// +// SheetCloseButton.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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")) + } +} diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 63346e26..4c96b201 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -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 = [] + @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 + } } } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index a643487a..a3ec8d0f 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -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 { diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index a9d7185b..eb188ed7 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -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") diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 9e4aaa48..dd72d8b6 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -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) } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index cbac2d2a..06fc865f 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -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) { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 4582e706..9613f227 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -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: ", ") + } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index addc835c..77b45a21 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -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) } diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 9c79f21e..d2cd3f16 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -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) } diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 5801888c..8e5d8bd2 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -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) } } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 4a84ca5a..90fd13c4 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -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) { diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index 9b18e498..d2a63e6e 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -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) diff --git a/bitchat/Views/Media/WaveformView.swift b/bitchat/Views/Media/WaveformView.swift index c925a1d5..b12ec6ee 100644 --- a/bitchat/Views/Media/WaveformView.swift +++ b/bitchat/Views/Media/WaveformView.swift @@ -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)) } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 42902202..244cd6dc 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -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: ", ") + } } diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index c8837983..1506c1da 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -36,8 +36,17 @@ struct MessageListView: View { var isTextFieldFocused: FocusState.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() -//} +// } diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 78e0ff93..1fba4f2d 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -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) diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index a777720a..bd83b678 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -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) diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift index 49891a1e..24624897 100644 --- a/bitchatTests/ChatComposerCoordinatorContextTests.swift +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -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) diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index be13d01e..88596f16 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -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) diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 62d1c538..e9c7549c 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -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]) diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 2a19afe9..60f913fa 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -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( diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index d7e6c863..8ffd2933 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -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) diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 3662f55a..b993471d 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -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) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 3e9c2daa..5a50f0ac 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -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 diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 52b4d2de..11ba1602 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -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 diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index 277e1f52..d7b7b7ef 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -121,4 +121,3 @@ final class TestNetworkHelper { _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } - diff --git a/bitchatTests/MimeTypeTests.swift b/bitchatTests/MimeTypeTests.swift index a6b20b10..b30436f3 100644 --- a/bitchatTests/MimeTypeTests.swift +++ b/bitchatTests/MimeTypeTests.swift @@ -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) diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 5a178f73..ab3b8641 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -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 { diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 9c5f2dc1..5d1a3129 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -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.. 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) diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 57714e45..39ed95bf 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -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.. 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, diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 09631d6a..786b481e 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -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) } diff --git a/bitchatTests/XChaCha20Poly1305CompatTests.swift b/bitchatTests/XChaCha20Poly1305CompatTests.swift index 9b607a9b..a90e1de0 100644 --- a/bitchatTests/XChaCha20Poly1305CompatTests.swift +++ b/bitchatTests/XChaCha20Poly1305CompatTests.swift @@ -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) diff --git a/localPackages/Arti/Package.swift b/localPackages/Arti/Package.swift index 1fe587da..c0e0ff5f 100644 --- a/localPackages/Arti/Package.swift +++ b/localPackages/Arti/Package.swift @@ -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" - ), + ) ] ) diff --git a/localPackages/BitFoundation/Package.swift b/localPackages/BitFoundation/Package.swift index a1de1be1..fbcf8e4e 100644 --- a/localPackages/BitFoundation/Package.swift +++ b/localPackages/BitFoundation/Package.swift @@ -21,7 +21,7 @@ let package = Package( .target( name: "BitFoundation", dependencies: [ - .product(name: "BitLogger", package: "BitLogger"), + .product(name: "BitLogger", package: "BitLogger") ], path: "Sources" ), diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift index 4016080f..ddf7c8fc 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/BinaryProtocolTests.swift @@ -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() 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) diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift index 5c8ca7c0..d2405b10 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestHelpers.swift @@ -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, diff --git a/scripts/check-perf-floors.sh b/scripts/check-perf-floors.sh index 27d72a9e..b51eb390 100755 --- a/scripts/check-perf-floors.sh +++ b/scripts/check-perf-floors.sh @@ -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 [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