mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
* 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>
383 lines
14 KiB
Swift
383 lines
14 KiB
Swift
//
|
|
// UnifiedPeerService.swift
|
|
// bitchat
|
|
//
|
|
// Unified peer state management combining mesh connectivity and favorites
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
|
@MainActor
|
|
final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|
|
|
// MARK: - Published Properties
|
|
|
|
@Published private(set) var peers: [BitchatPeer] = []
|
|
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
|
@Published private(set) var favorites: [BitchatPeer] = []
|
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
|
|
|
// MARK: - Private Properties
|
|
|
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
|
private var fingerprintCache: [PeerID: String] = [:]
|
|
private let meshService: Transport
|
|
private let idBridge: NostrIdentityBridge
|
|
private let identityManager: SecureIdentityStateManagerProtocol
|
|
weak var messageRouter: MessageRouter?
|
|
private let favoritesService = FavoritesPersistenceService.shared
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
meshService: Transport,
|
|
idBridge: NostrIdentityBridge,
|
|
identityManager: SecureIdentityStateManagerProtocol
|
|
) {
|
|
self.meshService = meshService
|
|
self.idBridge = idBridge
|
|
self.identityManager = identityManager
|
|
|
|
// Subscribe to changes from both services
|
|
setupSubscriptions()
|
|
|
|
// Perform initial update
|
|
Task { @MainActor in
|
|
updatePeers()
|
|
}
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupSubscriptions() {
|
|
// Subscribe to mesh peer updates via delegate (preferred over publishers)
|
|
meshService.peerEventsDelegate = self
|
|
|
|
// Also listen for favorite change notifications
|
|
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.updatePeers()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// TransportPeerEventsDelegate
|
|
func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) {
|
|
updatePeers()
|
|
}
|
|
|
|
// MARK: - Core Update Logic
|
|
|
|
private func updatePeers() {
|
|
let meshPeers = meshService.currentPeerSnapshots()
|
|
// If we have no direct links at all, peers should not be marked reachable
|
|
// "Reachable" means mesh-attached via at least one live link.
|
|
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
|
let favorites = favoritesService.favorites
|
|
|
|
var enrichedPeers: [BitchatPeer] = []
|
|
var connected: Set<PeerID> = []
|
|
var addedPeerIDs: Set<PeerID> = []
|
|
|
|
// Phase 1: Add all mesh peers (connected and reachable)
|
|
for peerInfo in meshPeers {
|
|
let peerID = peerInfo.peerID
|
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
|
|
|
let peer = buildPeerFromMesh(
|
|
peerInfo: peerInfo,
|
|
favorites: favorites,
|
|
meshAttached: hasAnyConnected
|
|
)
|
|
|
|
enrichedPeers.append(peer)
|
|
if peer.isConnected { connected.insert(peerID) }
|
|
addedPeerIDs.insert(peerID)
|
|
|
|
// Update fingerprint cache
|
|
if let publicKey = peerInfo.noisePublicKey {
|
|
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
|
}
|
|
}
|
|
|
|
// Phase 2: Add offline favorites that we actively favorite
|
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
|
let peerID = PeerID(hexData: favoriteKey)
|
|
|
|
// Skip if already added (connected peer)
|
|
if addedPeerIDs.contains(peerID) { continue }
|
|
|
|
// Skip if connected under different ID but same nickname
|
|
let isConnectedByNickname = enrichedPeers.contains {
|
|
$0.nickname == favorite.peerNickname && $0.isConnected
|
|
}
|
|
if isConnectedByNickname { continue }
|
|
|
|
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
|
enrichedPeers.append(peer)
|
|
addedPeerIDs.insert(peerID)
|
|
|
|
// Update fingerprint cache
|
|
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
|
}
|
|
|
|
// Phase 3: Sort peers
|
|
enrichedPeers.sort { lhs, rhs in
|
|
// Connectivity rank: connected > reachable > others
|
|
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
|
let lr = rank(lhs), rr = rank(rhs)
|
|
if lr != rr { return lr > rr }
|
|
// Then favorites inside same rank
|
|
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
|
// Finally alphabetical
|
|
return lhs.displayName < rhs.displayName
|
|
}
|
|
|
|
// Phase 4: Build subsets and indices
|
|
var favoritesList: [BitchatPeer] = []
|
|
var mutualsList: [BitchatPeer] = []
|
|
var newIndex: [PeerID: BitchatPeer] = [:]
|
|
|
|
for peer in enrichedPeers {
|
|
newIndex[peer.peerID] = peer
|
|
|
|
if peer.isFavorite {
|
|
favoritesList.append(peer)
|
|
}
|
|
if peer.isMutualFavorite {
|
|
mutualsList.append(peer)
|
|
}
|
|
}
|
|
|
|
// Phase 5: Filter out offline non-mutual peers and update published properties
|
|
let filtered = enrichedPeers.filter { p in
|
|
p.isConnected || p.isReachable || p.isMutualFavorite
|
|
}
|
|
self.peers = filtered
|
|
self.connectedPeerIDs = connected
|
|
self.favorites = favoritesList
|
|
self.mutualFavorites = mutualsList
|
|
self.peerIndex = newIndex
|
|
|
|
// Log summary (commented out to reduce noise)
|
|
// let connectedCount = connected.count
|
|
// let offlineCount = enrichedPeers.count - connectedCount
|
|
// Peer update: \(enrichedPeers.count) total (\(connectedCount) connected, \(offlineCount) offline)
|
|
}
|
|
|
|
// MARK: - Peer Building Helpers
|
|
|
|
private func buildPeerFromMesh(
|
|
peerInfo: TransportPeerSnapshot,
|
|
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
|
meshAttached: Bool
|
|
) -> BitchatPeer {
|
|
// Determine reachability based on lastSeen and identity trust
|
|
let now = Date()
|
|
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
|
let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
|
|
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
|
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
|
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
|
|
|
var peer = BitchatPeer(
|
|
peerID: peerInfo.peerID,
|
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
|
nickname: peerInfo.nickname,
|
|
lastSeen: peerInfo.lastSeen,
|
|
isConnected: peerInfo.isConnected,
|
|
isReachable: isReachable
|
|
)
|
|
|
|
// Check for favorite status
|
|
if let noiseKey = peerInfo.noisePublicKey,
|
|
let favoriteStatus = favorites[noiseKey] {
|
|
peer.favoriteStatus = favoriteStatus
|
|
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
|
}
|
|
|
|
return peer
|
|
}
|
|
|
|
private func buildPeerFromFavorite(
|
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
|
peerID: PeerID
|
|
) -> BitchatPeer {
|
|
var peer = BitchatPeer(
|
|
peerID: peerID,
|
|
noisePublicKey: favorite.peerNoisePublicKey,
|
|
nickname: favorite.peerNickname,
|
|
lastSeen: favorite.lastUpdated,
|
|
isConnected: false,
|
|
isReachable: false
|
|
)
|
|
|
|
peer.favoriteStatus = favorite
|
|
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
|
|
|
return peer
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Get peer by ID
|
|
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
|
return peerIndex[peerID]
|
|
}
|
|
|
|
/// Get peer ID for nickname
|
|
func getPeerID(for nickname: String) -> PeerID? {
|
|
for peer in peers {
|
|
if peer.displayName == nickname || peer.nickname == nickname {
|
|
return peer.peerID
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Check if peer is blocked
|
|
func isBlocked(_ peerID: PeerID) -> Bool {
|
|
// Get fingerprint
|
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
|
|
|
// Check SecureIdentityStateManager for block status
|
|
if let identity = identityManager.getSocialIdentity(for: fingerprint) {
|
|
return identity.isBlocked
|
|
}
|
|
|
|
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 {
|
|
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
|
return
|
|
}
|
|
|
|
let wasFavorite = peer.isFavorite
|
|
|
|
// Get the actual nickname for logging and saving
|
|
var actualNickname = peer.nickname
|
|
|
|
// Debug logging to understand the issue
|
|
SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
|
|
|
|
if actualNickname.isEmpty {
|
|
// Try to get from mesh service's current peer list
|
|
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
|
|
actualNickname = meshPeerNickname
|
|
SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
|
|
}
|
|
}
|
|
|
|
// Use displayName as fallback (which shows ID prefix if nickname is empty)
|
|
let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname
|
|
|
|
if wasFavorite {
|
|
// Remove favorite
|
|
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
} else {
|
|
// Get or derive peer's Nostr public key if not already known
|
|
var peerNostrKey = peer.nostrPublicKey
|
|
if peerNostrKey == nil {
|
|
// Try to get from NostrIdentityBridge association
|
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
|
}
|
|
|
|
// Add favorite
|
|
favoritesService.addFavorite(
|
|
peerNoisePublicKey: peer.noisePublicKey,
|
|
peerNostrPublicKey: peerNostrKey,
|
|
peerNickname: finalNickname
|
|
)
|
|
}
|
|
|
|
// Log the final nickname being saved
|
|
SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
|
|
|
|
// Send favorite notification to the peer via router (mesh or Nostr)
|
|
if let router = messageRouter {
|
|
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
} else {
|
|
// Fallback to mesh-only if router not yet wired
|
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
}
|
|
|
|
// Force update of peers to reflect the change
|
|
updatePeers()
|
|
|
|
// Force UI update by notifying SwiftUI directly
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
func getFingerprint(for peerID: PeerID) -> String? {
|
|
// Check cache first
|
|
if let cached = fingerprintCache[peerID] {
|
|
return cached
|
|
}
|
|
|
|
// Try to get from mesh service
|
|
if let fingerprint = meshService.getFingerprint(for: peerID) {
|
|
fingerprintCache[peerID] = fingerprint
|
|
return fingerprint
|
|
}
|
|
|
|
// Try to get from peer's public key
|
|
if let peer = getPeer(by: peerID) {
|
|
let fingerprint = peer.noisePublicKey.sha256Fingerprint()
|
|
fingerprintCache[peerID] = fingerprint
|
|
return fingerprint
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Compatibility Methods (for easy migration)
|
|
|
|
var allPeers: [BitchatPeer] { peers }
|
|
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
|
var favoritePeers: Set<String> {
|
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
|
}
|
|
var blockedUsers: Set<String> {
|
|
Set(peers.compactMap { peer in
|
|
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
|
})
|
|
}
|
|
}
|