mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
co-authored by
jack
jack
Claude Fable 5
parent
7a0c821807
commit
c74e212ea3
@@ -75,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
if let peerID, peerID.isGeoChat,
|
if let peerID, peerID.isGeoChat,
|
||||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
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 {
|
} else {
|
||||||
chatViewModel.sendMessage("/block \(displayName)")
|
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) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
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
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||||
? nil
|
? nil
|
||||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
|
|||||||
@@ -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" : {
|
"app_info.privacy.ephemeral.description" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"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" : {
|
"geohash_people.tooltip.blocked" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"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" : {
|
"mesh_peers.tooltip.new_messages" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"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" : {
|
"system.tor.dev_bypass" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -257,7 +257,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
return false
|
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
|
/// Toggle favorite status
|
||||||
func toggleFavorite(_ peerID: PeerID) {
|
func toggleFavorite(_ peerID: PeerID) {
|
||||||
guard let peer = getPeer(by: peerID) else {
|
guard let peer = getPeer(by: peerID) else {
|
||||||
|
|||||||
@@ -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 {
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||||
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
enum Privacy {
|
||||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||||
static let noTracking = AppInfoFeatureInfo(
|
static let noTracking = AppInfoFeatureInfo(
|
||||||
@@ -202,6 +222,28 @@ struct AppInfoView: View {
|
|||||||
FeatureRow(info: Strings.Features.mentions)
|
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
|
// Privacy
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Privacy.title)
|
SectionHeader(Strings.Privacy.title)
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ private struct ContentPeopleListView: View {
|
|||||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||||
@EnvironmentObject private var verificationModel: VerificationModel
|
@EnvironmentObject private var verificationModel: VerificationModel
|
||||||
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||||
@EnvironmentObject private var peerListModel: PeerListModel
|
@EnvironmentObject private var peerListModel: PeerListModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -231,6 +232,13 @@ private struct ContentPeopleListView: View {
|
|||||||
},
|
},
|
||||||
onShowFingerprint: { peerID in
|
onShowFingerprint: { peerID in
|
||||||
appChromeModel.showFingerprint(for: peerID)
|
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)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||||
case .offline:
|
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)
|
Text(headerState.displayName)
|
||||||
|
|||||||
@@ -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 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 unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
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 {
|
var body: some View {
|
||||||
@@ -46,7 +53,10 @@ struct GeohashPeopleList: View {
|
|||||||
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
|
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
|
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
|
||||||
let rowColor: Color = person.isMe ? .orange : assignedColor
|
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()
|
let (base, suffix) = person.displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
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
|
// 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: ", ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
|
|||||||
let onTapPeer: (PeerID) -> Void
|
let onTapPeer: (PeerID) -> Void
|
||||||
let onToggleFavorite: (PeerID) -> Void
|
let onToggleFavorite: (PeerID) -> Void
|
||||||
let onShowFingerprint: (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
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
|
|||||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
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 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 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 {
|
var body: some View {
|
||||||
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
|
|||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
|
.help(Strings.connected)
|
||||||
} else if peer.isReachable {
|
} else if peer.isReachable {
|
||||||
// Mesh-reachable (relayed): point.3 icon
|
// Mesh-reachable (relayed): point.3 icon
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
|
.help(Strings.reachable)
|
||||||
} else if peer.isMutualFavorite {
|
} else if peer.isMutualFavorite {
|
||||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
|
.help(Strings.nostr)
|
||||||
} else {
|
} else {
|
||||||
// Fallback icon for others (dimmed)
|
// Fallback icon for others (dimmed)
|
||||||
Image(systemName: "person")
|
Image(systemName: "person")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(palette.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
|
.help(Strings.offline)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (base, suffix) = peer.displayName.splitSuffix()
|
let (base, suffix) = peer.displayName.splitSuffix()
|
||||||
@@ -131,8 +152,53 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.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(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
|
// 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))
|
#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 {
|
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||||
|
|||||||
Reference in New Issue
Block a user