Mute nearby alerts for specific peers

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
This commit is contained in:
Taksh 2026-07-29 19:16:01 +03:00
parent 1f59e814f9
commit 481bf4cf4a
11 changed files with 201 additions and 6 deletions

View File

@ -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)"

View File

@ -187,6 +187,11 @@ struct IdentityCache: Codable {
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// 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<String>? = 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<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
nearbyNotificationMutedFingerprints = try container.decodeIfPresent(Set<String>.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)

View File

@ -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 {

View File

@ -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
/// emptypopulated 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
// emptypopulated 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
)
}

View File

@ -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() {

View File

@ -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

View File

@ -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: ", ")
}
}

View File

@ -64,11 +64,16 @@ private final class MockChatPeerListContext: ChatPeerListContext {
// Notifications
private(set) var networkAvailableNotifications: [Int] = []
var suppressedNearbyPeerIDs: Set<PeerID> = []
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])
}
}

View File

@ -70,6 +70,24 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
blockedFingerprints.remove(fingerprint)
}
}
private var nearbyNotificationMutedFingerprints: Set<String> = []
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)

View File

@ -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)

View File

@ -241,6 +241,24 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
socialIdentities[fingerprint] = identity
}
private var nearbyMuted: Set<String> = []
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)
}