diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index cb34ca62..461449cc 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -75,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/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 8895e6fa..ea1aab1f 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" : { @@ -23742,6 +23898,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" : { @@ -31492,6 +31684,78 @@ } } }, + "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" : { @@ -33819,6 +34083,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" : { 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/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 51e93024..705cd194 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) } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index cce9499e..05a0154a 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( @@ -202,6 +222,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/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 55010e37..d8da54cd 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 @@ -231,6 +232,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) + } } ) } @@ -495,7 +503,10 @@ 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(.secondary) } Text(headerState.displayName) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 4582e706..0df2ce50 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,10 @@ 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) + .font(.bitchatSystem(size: 12)) + .foregroundColor(rowColor) + .help(person.isTeleported ? Strings.teleported : Strings.nearby) let (base, suffix) = person.displayName.splitSuffix() HStack(spacing: 0) { @@ -105,6 +115,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 +150,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/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 42902202..dffc08d2 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() @@ -131,8 +152,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 +213,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/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index c910af9b..bf94a0ba 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests { #expect(service.isBlocked(peerID)) } + + @Test @MainActor + func setBlocked_persistsByFingerprintAndToggles() async { + let transport = MockTransport() + let identity = TestIdentityManager() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) + + let peerID = PeerID(str: "00000000000000EE") + let fingerprint = "fp-target" + transport.peerFingerprints[peerID] = fingerprint + + // Blocking resolves and persists by the peer's fingerprint. + let resolved = service.setBlocked(peerID, blocked: true) + #expect(resolved == fingerprint) + #expect(identity.isBlocked(fingerprint: fingerprint)) + #expect(service.isBlocked(peerID)) + + // Unblocking clears it against the same identity. + let unresolved = service.setBlocked(peerID, blocked: false) + #expect(unresolved == fingerprint) + #expect(!identity.isBlocked(fingerprint: fingerprint)) + #expect(!service.isBlocked(peerID)) + } + + @Test @MainActor + func setBlocked_unknownIdentityReturnsNil() async { + let transport = MockTransport() + let identity = TestIdentityManager() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity) + + // No fingerprint resolvable for this peer (offline & unknown). + let peerID = PeerID(str: "00000000000000FF") + + #expect(service.setBlocked(peerID, blocked: true) == nil) + #expect(!service.isBlocked(peerID)) + } } private final class TestIdentityManager: SecureIdentityStateManagerProtocol {