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 0db69b40..96e61834 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -57,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 } @@ -115,7 +118,7 @@ final class ChatPeerListCoordinator: @unchecked Sendable { } } -private extension ChatPeerListCoordinator { +extension ChatPeerListCoordinator { @MainActor func handlePeerListUpdate(_ peers: [PeerID]) { context.isConnected = !peers.isEmpty @@ -147,14 +150,19 @@ private extension ChatPeerListCoordinator { return } - invalidateNetworkEmptyTimer() context.recordMeshSightings(peerIDs: meshPeers) // 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. + // 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) } - guard !countablePeers.isEmpty else { return } + if countablePeers.isEmpty { + scheduleNetworkEmptyTimer() + return + } + + invalidateNetworkEmptyTimer() let countableSet = Set(countablePeers) let newPeers = countableSet.subtracting(recentlySeenPeers) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 899acf6b..08968096 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1317,7 +1317,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage @MainActor func setNearbyNotificationMuted(for peerID: PeerID, muted: Bool) { - guard let fingerprint = getFingerprint(for: peerID) else { return } + 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() } diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index f94aacaa..840f36e5 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -304,4 +304,35 @@ struct ChatPeerListCoordinatorContextTests { 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]) + } }