Make peer lists accessible and actionable; block mesh peers by stable identity (#1360)

* Make peer lists accessible and actionable; block by stable identity

Who you can reach — the app's most important fact — was encoded in
unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no
actions (block/favorite/verify were slash-command-only).

- Both peer lists become real accessibility citizens: each row is one
  element announcing name, reachability, and favorite/unread/blocked
  state, with a button trait and custom actions for the gesture-only
  interactions. Neither file previously had a single accessibility
  modifier. Reachability icons gain tooltips reusing existing strings;
  teleported vs in-area pins are explained.
- Mesh rows gain the context menu the geohash list already had: direct
  message, favorite, show fingerprint, block/unblock. The fingerprint
  double-tap, previously shadowed by the single tap, is reordered so it
  fires.
- The DM header's offline state (previously EmptyView — absence of a
  glyph as the only signal) becomes a dimmed "offline" tag, and a
  geohash DM — always Nostr-routed — no longer mislabels itself
  "offline".
- App Info gains a SYMBOLS legend defining every glyph the lists and
  headers use; nothing defined them before.
- Mesh block/unblock now resolve by the peer's stable Noise identity
  instead of a `/block <displayName>` string, so the exact tapped row is
  affected and offline peers can be unblocked (with covering tests).

New strings are added source-language (en) only.

* Surface block/unblock feedback in the conversation where it was triggered

setMeshPeerBlocked silently returned when the peer's identity could not
be resolved (e.g. long-press-blocking an old public message from a
sender who left and was never a favorite), where the /block command
printed "cannot block X: not found or unable to verify identity" — post
that same message from the guard branch.

Both the failure and confirmation messages now route through
addCommandOutput instead of addSystemMessage, so blocking from inside a
private chat prints into that chat rather than invisibly into the
public timeline (same routing #1363 applied to command output).

The confirmation also reuses the /block wording ("blocked X. you will
no longer receive messages from them") for parity with the command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove dead accessibility label and unreachable /unblock fallback

The favorite button's .accessibilityLabel in MeshPeerList is
unreachable: the row-level .accessibilityElement(children: .ignore)
swallows child elements, and the row's custom accessibility action
already covers favoriting.

ConversationUIModel.unblock is only called from the mesh peer list with
a non-optional mesh peerID, so the "/unblock <name>" fallback branch
could never run — take PeerID directly and drop the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hot Pixel Group
2026-07-06 10:12:53 +02:00
committed by GitHub
co-authored by jack jack Claude Fable 5
parent 7a0c821807
commit c74e212ea3
10 changed files with 617 additions and 5 deletions
+12
View File
@@ -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 <displayName>` command
/// cannot resolve.
func unblock(peerID: PeerID, displayName: String) {
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
+7 -1
View File
@@ -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)
+312
View File
@@ -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" : {
+23 -1
View File
@@ -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 {
+42
View File
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
// command, these resolve and persist the block by the peer's stable
// fingerprint (derived from `peerID`), so the exact tapped peer is
// (un)blocked unambiguous across nickname collisions and functional for
// offline peers that can no longer be resolved through the mesh service.
@MainActor
func blockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
}
@MainActor
func unblockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
}
@MainActor
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
),
locale: .current,
displayName
)
)
return
}
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
comment: "System message shown when a mesh peer is blocked or unblocked"
),
locale: .current,
displayName
)
)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
}
+42
View File
@@ -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)
+12 -1
View File
@@ -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)
+41 -1
View File
@@ -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: ", ")
}
}
+88 -1
View File
@@ -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: ", ")
}
}
@@ -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 {