diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b365d09d..ef4fae7e 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -8,6 +8,7 @@ struct MeshPeerRow: Identifiable, Equatable { let isMe: Bool let hasUnread: Bool let isBlocked: Bool + let isNearbyNotificationMuted: Bool let isFavorite: Bool let isConnected: Bool let isReachable: Bool @@ -95,6 +96,15 @@ final class PeerListModel: ObservableObject { chatViewModel.toggleFavorite(peerID: peerID) } + func toggleNearbyNotificationMute(peerID: PeerID) { + let muted = !chatViewModel.isNearbyNotificationMuted(for: peerID) + chatViewModel.setNearbyNotificationMuted(for: peerID, muted: muted) + // Mute lives on the identity manager — PeerListModel's publishers + // do not observe it, so refresh now or the row's bell / menu label + // stay stale until some unrelated peer update (#1541 Codex). + refresh() + } + func openGeohashDirectMessage(with pubkeyHex: String) { chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex) } @@ -217,6 +227,7 @@ final class PeerListModel: ObservableObject { isMe: isMe, hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID), isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID), + isNearbyNotificationMuted: !isMe && chatViewModel.isNearbyNotificationMuted(for: peer.peerID), isFavorite: peer.favoriteStatus?.isFavorite ?? false, isConnected: peer.isConnected, isReachable: peer.isReachable, @@ -248,7 +259,7 @@ final class PeerListModel: ObservableObject { self.groupRows = groupRows renderID = ( meshRows.map { - "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" + "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)-\($0.isNearbyNotificationMuted)" } + geohashPeople.map { "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 8a72fcba..6bed2eb0 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -187,6 +187,11 @@ struct IdentityCache: Codable { // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] + // Noise fingerprints muted for the "bitchatters nearby" local + // notification. Optional so caches written before this feature decode + // cleanly. Local-only; never transmitted. + var nearbyNotificationMutedFingerprints: Set? = nil + // Vouching (transitive verification). All three fields are Optional so // caches persisted before this feature decode cleanly — decodeIfPresent // is used below, and a missing key must not trip the "unreadable cache" @@ -237,6 +242,7 @@ struct IdentityCache: Codable { verifiedFingerprints = try container.decodeIfPresent(Set.self, forKey: .verifiedFingerprints) ?? [] lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:] blockedNostrPubkeys = try container.decodeIfPresent(Set.self, forKey: .blockedNostrPubkeys) ?? [] + nearbyNotificationMutedFingerprints = try container.decodeIfPresent(Set.self, forKey: .nearbyNotificationMutedFingerprints) vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee) vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt) verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt) diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 4204e940..132f47aa 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -113,6 +113,14 @@ protocol SecureIdentityStateManagerProtocol { // MARK: Blocked Users Management func isBlocked(fingerprint: String) -> Bool func setBlocked(_ fingerprint: String, isBlocked: Bool) + + // MARK: Nearby-notification mute + /// Explicit proximity mute (does not include blocked peers). + func isNearbyNotificationMuted(fingerprint: String) -> Bool + /// True when this fingerprint should not count toward nearby alerts + /// (explicit mute or blocked). + func suppressesNearbyNotification(fingerprint: String) -> Bool + func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) // MARK: Geohash (Nostr) Blocking func isNostrBlocked(pubkeyHexLowercased: String) -> Bool @@ -602,6 +610,42 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } + // MARK: - Nearby-notification mute + + func isNearbyNotificationMuted(fingerprint: String) -> Bool { + queue.sync { + cache.nearbyNotificationMutedFingerprints?.contains(fingerprint) == true + } + } + + /// True when this fingerprint should not contribute to the "bitchatters + /// nearby" local notification. Blocked peers are always suppressed; + /// additionally, peers the user has explicitly muted for proximity + /// alerts (typically their own other devices) are suppressed without a + /// full block. + func suppressesNearbyNotification(fingerprint: String) -> Bool { + queue.sync { + if cache.socialIdentities[fingerprint]?.isBlocked == true { + return true + } + return cache.nearbyNotificationMutedFingerprints?.contains(fingerprint) == true + } + } + + func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) { + guard !fingerprint.isEmpty else { return } + queue.sync(flags: .barrier) { + var mutedSet = self.cache.nearbyNotificationMutedFingerprints ?? [] + if muted { + mutedSet.insert(fingerprint) + } else { + mutedSet.remove(fingerprint) + } + self.cache.nearbyNotificationMutedFingerprints = mutedSet + self.saveIdentityCache() + } + } + // MARK: - Geohash (Nostr) Blocking func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f869ab85..651b4b4f 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -62336,6 +62336,378 @@ } } }, + "mesh_peers.action.mute_nearby" : { + "comment" : "Context menu action to stop nearby notifications for this peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كتم تنبيهات القرب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি সতর্কতা নিঃশব্দ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahe benachrichtigungen stummschalten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mute nearby alerts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "silenciar alertas cercanas" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بی‌صدا کردن هشدارهای نزدیک" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-mute ang nearby alerts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "couper les alertes de proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השתק התראות קרובות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नज़दीकी अलर्ट म्यूट करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bisukan peringatan terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "silenzia avvisi vicini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くの通知をミュート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 알림 음소거" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "redam amaran berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका अलर्ट म्यूट गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nabije meldingen dempen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wycisz alerty w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "silenciar alertas próximos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "silenciar alertas próximos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "отключить уведомления о рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tysta närhetsaviseringar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள எச்சரிக்கைகளை முடக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปิดเสียงการแจ้งเตือนใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki uyarıları sessize al" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вимкнути сповіщення про близькість" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی الرٹس خاموش کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tắt cảnh báo gần" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "静音附近提醒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "靜音附近提醒" + } + } + } + }, + "mesh_peers.action.unmute_nearby" : { + "comment" : "Context menu action to resume nearby notifications for this peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إلغاء كتم تنبيهات القرب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি সতর্কতা আনমিউট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahe benachrichtigungen wieder an" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unmute nearby alerts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "reactivar alertas cercanas" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "صدادار کردن هشدارهای نزدیک" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-unmute ang nearby alerts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "rétablir les alertes de proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטל השתקת התראות קרובות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नज़दीकी अलर्ट अनम्यूट करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bunyikan lagi peringatan terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "riattiva avvisi vicini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くの通知のミュート解除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 알림 음소거 해제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "nyahredam amaran berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका अलर्ट अनम्यूट गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nabije meldingen dempen opheffen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przywróć alerty w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "reativar alertas próximos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "reativar alertas próximos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "включить уведомления о рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "slå på närhetsaviseringar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள எச்சரிக்கைகளை இயக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดเสียงการแจ้งเตือนใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki uyarıların sesini aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "увімкнути сповіщення про близькість" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی الرٹس بحال کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bật lại cảnh báo gần" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消静音附近提醒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消靜音附近提醒" + } + } + } + }, "mesh_peers.state.blocked" : { "comment" : "State label for a blocked peer", "extractionState" : "manual", @@ -62708,6 +63080,192 @@ } } }, + "mesh_peers.state.nearby_muted" : { + "comment" : "State label for a peer muted for proximity notifications", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنبيهات القرب مكتومة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি সতর্কতা নিঃশব্দ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nahe benachrichtigungen stumm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nearby alerts muted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alertas cercanas silenciadas" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هشدارهای نزدیک بی‌صدا" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-mute ang nearby alerts" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "alertes de proximité coupées" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראות קרובות מושתקות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नज़दीकी अलर्ट म्यूट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "peringatan terdekat dibisukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "avvisi vicini silenziati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くの通知をミュート中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 알림 음소거됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "amaran berdekatan diredam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका अलर्ट म्यूट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nabije meldingen gedempt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alerty w pobliżu wyciszone" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "alertas próximos silenciados" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "alertas próximos silenciados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "уведомления о рядом отключены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "närhetsaviseringar tystade" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள எச்சரிக்கைகள் முடக்கப்பட்டன" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปิดเสียงการแจ้งเตือนใกล้เคียงแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki uyarılar sessiz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "сповіщення про близькість вимкнено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی الرٹس خاموش" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã tắt cảnh báo gần" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已静音附近提醒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已靜音附近提醒" + } + } + } + }, "mesh_peers.state.offline" : { "comment" : "State label for a peer that is not currently reachable", "extractionState" : "manual", diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 7646523f..96e61834 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -37,6 +37,11 @@ protocol ChatPeerListContext: AnyObject { /// Posts the "bitchatters nearby" local notification. func notifyNetworkAvailable(peerCount: Int) + /// True when this peer should not count toward the nearby-notification + /// empty→populated transition (blocked peers, or peers the user muted + /// for proximity alerts — typically their own other devices). + func suppressesNearbyNotification(for peerID: PeerID) -> Bool + /// Records peers seen within range for the daily ambient sightings tally. func recordMeshSightings(peerIDs: [PeerID]) } @@ -52,10 +57,13 @@ extension ChatViewModel: ChatPeerListContext { // call. func activeMeshPeerCount() -> Int { + // Exclude blocked / proximity-muted peers so a muted-only mesh + // still counts as empty for the notification reset timers. meshService .currentPeerSnapshots() .filter { snapshot in - snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + (snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)) + && !suppressesNearbyNotification(for: snapshot.peerID) } .count } @@ -110,7 +118,7 @@ final class ChatPeerListCoordinator: @unchecked Sendable { } } -private extension ChatPeerListCoordinator { +extension ChatPeerListCoordinator { @MainActor func handlePeerListUpdate(_ peers: [PeerID]) { context.isConnected = !peers.isEmpty @@ -142,15 +150,27 @@ private extension ChatPeerListCoordinator { return } - invalidateNetworkEmptyTimer() context.recordMeshSightings(peerIDs: meshPeers) - let newPeers = meshPeerSet.subtracting(recentlySeenPeers) - // Record every sighted peer even when no notification fires. A peer + // Only peers that aren't blocked / proximity-muted count toward the + // empty→populated notification and the reset-path emptiness check. + // A muted-only mesh stays "empty" so a stranger joining later still + // alerts, and so meshWasEmpty can reset after the first alert. + let countablePeers = meshPeers.filter { !context.suppressesNearbyNotification(for: $0) } + if countablePeers.isEmpty { + scheduleNetworkEmptyTimer() + return + } + + invalidateNetworkEmptyTimer() + + let countableSet = Set(countablePeers) + let newPeers = countableSet.subtracting(recentlySeenPeers) + // Record every countable peer even when no notification fires. A peer // first seen during the cooldown (or while already meshed) must not // still count as "new" at some later peer-list event — that re-fired // the notification while devices sat idle and connected. - recentlySeenPeers.formUnion(meshPeerSet) + recentlySeenPeers.formUnion(countableSet) let cameFromEmpty = meshWasEmpty meshWasEmpty = false @@ -159,9 +179,9 @@ private extension ChatPeerListCoordinator { if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds { lastNetworkNotificationTime = Date() - context.notifyNetworkAvailable(peerCount: meshPeers.count) + context.notifyNetworkAvailable(peerCount: countablePeers.count) SecureLogger.info( - "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", + "👥 Sent bitchatters nearby notification for \(countablePeers.count) mesh peers (new: \(newPeers.count))", category: .session ) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 12adfda9..08968096 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1303,6 +1303,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage peerIdentityCoordinator.isPeerBlocked(peerID) } + @MainActor + func suppressesNearbyNotification(for peerID: PeerID) -> Bool { + guard let fingerprint = getFingerprint(for: peerID) else { return false } + return identityManager.suppressesNearbyNotification(fingerprint: fingerprint) + } + + @MainActor + func isNearbyNotificationMuted(for peerID: PeerID) -> Bool { + guard let fingerprint = getFingerprint(for: peerID) else { return false } + return identityManager.isNearbyNotificationMuted(fingerprint: fingerprint) + } + + @MainActor + func setNearbyNotificationMuted(for peerID: PeerID, muted: Bool) { + guard let fingerprint = getFingerprint(for: peerID) else { + // Pre-handshake peers have no Noise fingerprint yet — the mute + // key is fingerprint-stable, so there is nothing to persist. + SecureLogger.debug( + "🔇 Nearby-notification mute no-op for \(peerID.id.prefix(8))… (no fingerprint yet)", + category: .session + ) + return + } + identityManager.setNearbyNotificationMuted(fingerprint, muted: muted) + objectWillChange.send() + } + // Helper method to update selectedPrivateChatPeer if fingerprint matches @MainActor func updatePrivateChatPeerIfNeeded() { diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..ff018461 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -388,6 +388,9 @@ private struct ContentPeopleListView: View { } else { conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName) } + }, + onToggleNearbyNotificationMute: { peer in + peerListModel.toggleNearbyNotificationMute(peerID: peer.peerID) } ) // People in this area but beyond radio range, and diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d94af5f..baa4b9d2 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -10,6 +10,8 @@ struct MeshPeerList: View { /// 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 + /// Mute/unmute this peer for the "bitchatters nearby" notification. + var onToggleNearbyNotificationMute: ((MeshPeerRow) -> Void)? = nil @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] @@ -34,6 +36,9 @@ struct MeshPeerList: View { 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") + static let muteNearby = String(localized: "mesh_peers.action.mute_nearby", defaultValue: "Mute nearby alerts", comment: "Context menu action to stop nearby notifications for this peer") + static let unmuteNearby = String(localized: "mesh_peers.action.unmute_nearby", defaultValue: "Unmute nearby alerts", comment: "Context menu action to resume nearby notifications for this peer") + static let nearbyMuted = String(localized: "mesh_peers.state.nearby_muted", defaultValue: "nearby alerts muted", comment: "State label for a peer muted for proximity notifications") } var body: some View { @@ -114,6 +119,11 @@ struct MeshPeerList: View { .font(.bitchatSystem(size: 10)) .foregroundColor(.red) .help(Strings.blockedTooltip) + } else if peer.isNearbyNotificationMuted { + Image(systemName: "bell.slash") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.secondary) + .help(Strings.nearbyMuted) } if !isMe { @@ -195,6 +205,11 @@ struct MeshPeerList: View { Button(Strings.showFingerprint) { onShowFingerprint(peer.peerID) } + if let onToggleNearbyNotificationMute { + Button(peer.isNearbyNotificationMuted ? Strings.unmuteNearby : Strings.muteNearby) { + onToggleNearbyNotificationMute(peer) + } + } if let onToggleBlock { if peer.isBlocked { Button(Strings.unblock) { @@ -220,6 +235,11 @@ struct MeshPeerList: View { Button(Strings.showFingerprint) { onShowFingerprint(peer.peerID) } + if let onToggleNearbyNotificationMute { + Button(peer.isNearbyNotificationMuted ? Strings.unmuteNearby : Strings.muteNearby) { + onToggleNearbyNotificationMute(peer) + } + } if let onToggleBlock { Button(peer.isBlocked ? Strings.unblock : Strings.block) { onToggleBlock(peer) @@ -261,6 +281,7 @@ struct MeshPeerList: View { if peer.isFavorite { parts.append(Strings.favorite) } if peer.hasUnread { parts.append(Strings.unread) } if peer.isBlocked { parts.append(Strings.blocked) } + if peer.isNearbyNotificationMuted { parts.append(Strings.nearbyMuted) } return parts.joined(separator: ", ") } } diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index d7882f0e..840f36e5 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -64,11 +64,16 @@ private final class MockChatPeerListContext: ChatPeerListContext { // Notifications private(set) var networkAvailableNotifications: [Int] = [] + var suppressedNearbyPeerIDs: Set = [] func notifyNetworkAvailable(peerCount: Int) { networkAvailableNotifications.append(peerCount) } + func suppressesNearbyNotification(for peerID: PeerID) -> Bool { + suppressedNearbyPeerIDs.contains(peerID) + } + // Sightings private(set) var recordedSightings: [[PeerID]] = [] @@ -279,4 +284,55 @@ struct ChatPeerListCoordinatorContextTests { await drainMainActorTasks() #expect(context.networkAvailableNotifications.isEmpty) } + + @Test @MainActor + func didUpdatePeerList_mutedPeersDoNotTriggerNearbyNotification() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let mutedPeer = PeerID(str: "0011223344556677") + let stranger = PeerID(str: "8899aabbccddeeff") + context.connectedMeshPeers = [mutedPeer, stranger] + context.suppressedNearbyPeerIDs = [mutedPeer] + + // Only the muted (own) device is nearby: stay quiet. + coordinator.didUpdatePeerList([mutedPeer]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications.isEmpty) + + // A stranger joining after muted-only mesh still alerts. + coordinator.didUpdatePeerList([mutedPeer, stranger]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + } + + @Test @MainActor + func didUpdatePeerList_mutedOnlyMeshResetsEmptyGateForNextStranger() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0) + let mutedPeer = PeerID(str: "0011223344556677") + let stranger = PeerID(str: "8899aabbccddeeff") + context.connectedMeshPeers = [mutedPeer, stranger] + context.suppressedNearbyPeerIDs = [mutedPeer] + + // First stranger alerts and marks the mesh populated. + coordinator.didUpdatePeerList([mutedPeer, stranger]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // Stranger leaves; only the muted peer remains. Reset-path count + // must treat that as empty (activeMeshPeerCount excludes muted). + context.connectedMeshPeers = [mutedPeer] + context.activeMeshPeerCountValue = 0 + coordinator.didUpdatePeerList([mutedPeer]) + await drainMainActorTasks() + coordinator.handleNetworkEmptyTimerFired() + + // A later stranger must alert again — the muted peer must not pin + // meshWasEmpty to false forever. + context.connectedMeshPeers = [mutedPeer, stranger] + context.activeMeshPeerCountValue = 1 + coordinator.didUpdatePeerList([mutedPeer, stranger]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1, 1]) + } } diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 930d57ff..ea193092 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -70,6 +70,24 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { blockedFingerprints.remove(fingerprint) } } + + private var nearbyNotificationMutedFingerprints: Set = [] + + func isNearbyNotificationMuted(fingerprint: String) -> Bool { + nearbyNotificationMutedFingerprints.contains(fingerprint) + } + + func suppressesNearbyNotification(fingerprint: String) -> Bool { + isBlocked(fingerprint: fingerprint) || isNearbyNotificationMuted(fingerprint: fingerprint) + } + + func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) { + if muted { + nearbyNotificationMutedFingerprints.insert(fingerprint) + } else { + nearbyNotificationMutedFingerprints.remove(fingerprint) + } + } func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { blockedNostrPubkeys.contains(pubkeyHexLowercased) diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index aae65dab..03176182 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -195,6 +195,28 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32))) } + func test_nearbyNotificationMute_persistsAndSuppressesWithoutBlocking() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + let fingerprint = String(repeating: "ab", count: 32) + + XCTAssertFalse(manager.isNearbyNotificationMuted(fingerprint: fingerprint)) + XCTAssertFalse(manager.suppressesNearbyNotification(fingerprint: fingerprint)) + + manager.setNearbyNotificationMuted(fingerprint, muted: true) + let muted = await waitUntil { manager.isNearbyNotificationMuted(fingerprint: fingerprint) } + XCTAssertTrue(muted) + XCTAssertTrue(manager.suppressesNearbyNotification(fingerprint: fingerprint)) + XCTAssertFalse(manager.isBlocked(fingerprint: fingerprint)) + + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertTrue(reloaded.isNearbyNotificationMuted(fingerprint: fingerprint)) + + manager.setNearbyNotificationMuted(fingerprint, muted: false) + let unmuted = await waitUntil { !manager.isNearbyNotificationMuted(fingerprint: fingerprint) } + XCTAssertTrue(unmuted) + } + func test_setVerified_updatesTrustLevelAndVerifiedSet() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "cd", count: 32) diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 4bc6f775..cd6c4004 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -241,6 +241,24 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { socialIdentities[fingerprint] = identity } + private var nearbyMuted: Set = [] + + func isNearbyNotificationMuted(fingerprint: String) -> Bool { + nearbyMuted.contains(fingerprint) + } + + func suppressesNearbyNotification(fingerprint: String) -> Bool { + isBlocked(fingerprint: fingerprint) || nearbyMuted.contains(fingerprint) + } + + func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) { + if muted { + nearbyMuted.insert(fingerprint) + } else { + nearbyMuted.remove(fingerprint) + } + } + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { blockedNostr.contains(pubkeyHexLowercased) }