From 481bf4cf4a5f216a507f14cbd9017149b127453c Mon Sep 17 00:00:00 2001 From: Taksh Date: Wed, 29 Jul 2026 19:16:01 +0300 Subject: [PATCH] Mute nearby alerts for specific peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your other devices on the same mesh stop firing the bitchatters-nearby notification without needing a full block. Context menu on the peer row; muted peers don't count toward the empty→populated gate. Closes #1523 --- bitchat/App/PeerListModel.swift | 9 +++- bitchat/Identity/IdentityModels.swift | 6 +++ .../Identity/SecureIdentityStateManager.swift | 44 +++++++++++++++++++ .../ViewModels/ChatPeerListCoordinator.swift | 22 +++++++--- bitchat/ViewModels/ChatViewModel.swift | 19 ++++++++ bitchat/Views/ContentSheetViews.swift | 3 ++ bitchat/Views/MeshPeerList.swift | 21 +++++++++ .../ChatPeerListCoordinatorContextTests.swift | 25 +++++++++++ bitchatTests/Mocks/MockIdentityManager.swift | 18 ++++++++ .../SecureIdentityStateManagerTests.swift | 22 ++++++++++ .../Services/UnifiedPeerServiceTests.swift | 18 ++++++++ 11 files changed, 201 insertions(+), 6 deletions(-) diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b365d09d..ae8f5641 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,11 @@ final class PeerListModel: ObservableObject { chatViewModel.toggleFavorite(peerID: peerID) } + func toggleNearbyNotificationMute(peerID: PeerID) { + let muted = !chatViewModel.isNearbyNotificationMuted(for: peerID) + chatViewModel.setNearbyNotificationMuted(for: peerID, muted: muted) + } + func openGeohashDirectMessage(with pubkeyHex: String) { chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex) } @@ -217,6 +223,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 +255,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/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 7646523f..0db69b40 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]) } @@ -145,12 +150,19 @@ private extension ChatPeerListCoordinator { 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. Muted-only meshes stay "empty" for + // that gate so a stranger joining later still alerts. + let countablePeers = meshPeers.filter { !context.suppressesNearbyNotification(for: $0) } + guard !countablePeers.isEmpty else { return } + + 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 +171,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..899acf6b 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1303,6 +1303,25 @@ 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 { 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..f94aacaa 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,24 @@ 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]) + } } 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) }