From 1387dbc848b43c3092d8b3b37912ce84e2889755 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 29 Aug 2026 16:50:05 +0530 Subject: [PATCH 1/6] Add ConversationNotificationMuteStore for per-conversation mutes DM mutes key only on fingerprint (no peer-ID fallback) so a mute cannot silently stop applying after peer-ID rotation. --- .../ConversationNotificationMuteStore.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 bitchat/Services/ConversationNotificationMuteStore.swift diff --git a/bitchat/Services/ConversationNotificationMuteStore.swift b/bitchat/Services/ConversationNotificationMuteStore.swift new file mode 100644 index 00000000..39c116ca --- /dev/null +++ b/bitchat/Services/ConversationNotificationMuteStore.swift @@ -0,0 +1,72 @@ +// +// ConversationNotificationMuteStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Persistent mute preferences for individual conversations (#1594). +/// +/// Muted conversations still receive and store messages; only local +/// notifications are suppressed. Keys prefer stable fingerprints for DMs and +/// lowercase geohashes for location channels. +enum ConversationNotificationMuteStore { + private static let mutedKeysDefaultsKey = "notifications.mutedConversationKeys" + + enum Scope: Hashable, Equatable { + case direct(identityKey: String) + case geohash(String) + + var storageKey: String { + switch self { + case .direct(let identityKey): + return "dm:\(identityKey.lowercased())" + case .geohash(let geohash): + return "geo:\(geohash.lowercased())" + } + } + + /// Stable DM key, or `nil` when no fingerprint is known yet. + /// + /// There is deliberately no routing-peer-ID fallback. Peer IDs rotate, + /// so a mute keyed on one would stop applying after the next rotation + /// while the UI still showed the conversation as muted — worse than + /// refusing to mute at all. Callers must treat `nil` as "cannot mute + /// yet" (see `PrivateConversationModel.canToggleSelectedConversationNotificationMute`). + /// + /// Both the toggle and the notification path resolve the fingerprint + /// through `stableConversationIdentity(for:)`, so the key written and + /// the key looked up cannot disagree. + static func direct(fingerprint: String?) -> Scope? { + guard let key = fingerprint?.trimmedOrNilIfEmpty else { return nil } + return .direct(identityKey: key) + } + } + + static func isMuted(_ scope: Scope, in defaults: UserDefaults = .standard) -> Bool { + mutedKeys(in: defaults).contains(scope.storageKey) + } + + static func setMuted(_ muted: Bool, for scope: Scope, in defaults: UserDefaults = .standard) { + var keys = mutedKeys(in: defaults) + if muted { + keys.insert(scope.storageKey) + } else { + keys.remove(scope.storageKey) + } + defaults.set(Array(keys).sorted(), forKey: mutedKeysDefaultsKey) + } + + static func mutedKeys(in defaults: UserDefaults = .standard) -> Set { + Set(defaults.stringArray(forKey: mutedKeysDefaultsKey) ?? []) + } + + /// Panic-wipe hook: a wiped device should not inherit another person's + /// notification preferences. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: mutedKeysDefaultsKey) + } +} From e7051a431d74069ea8fa619248473b9aa26e2ef5 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 29 Aug 2026 16:50:06 +0530 Subject: [PATCH 2/6] Wire mute toggles, notification skip, and people-sheet badge Toggle and notification paths share stableConversationIdentity(for:) so the key written and the key looked up cannot disagree. Pre-handshake DMs disable the control instead of no-op'ing. --- bitchat/App/PeerListModel.swift | 16 +++++++++- bitchat/App/PrivateConversationModels.swift | 25 +++++++++++++++ bitchat/Services/NotificationService.swift | 25 +++++++++++++-- .../ChatPrivateConversationCoordinator.swift | 16 +++++++++- bitchat/ViewModels/ChatViewModel.swift | 1 + bitchat/Views/ContentHeaderView.swift | 31 +++++++++++++++++++ bitchat/Views/ContentSheetViews.swift | 25 +++++++++++++++ bitchat/Views/MeshPeerList.swift | 9 ++++++ 8 files changed, 144 insertions(+), 4 deletions(-) diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 4f1fc633..f641d9d8 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -17,6 +17,9 @@ struct MeshPeerRow: Identifiable, Equatable { /// Vouched-for by someone I verified, without an explicit verification of /// mine — rendered as the unfilled seal (verified gets the filled one). let showsVouchedBadge: Bool + /// This peer's direct conversation has notifications muted. Surfaced in the + /// row so muting is discoverable outside the open thread's bell (#1606). + let notificationsMuted: Bool var id: String { peerID.id } } @@ -231,6 +234,16 @@ final class PeerListModel: ObservableObject { let meshRows = allPeers.map { peer in let isMe = peer.peerID == myPeerID let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID) + // Same resolver the mute toggle and the notification path use, so + // the row can never disagree with either (#1606). + let muteScope: ConversationNotificationMuteStore.Scope? = isMe + ? nil + : .direct(fingerprint: chatViewModel.stableConversationIdentity(for: peer.peerID)) + // Hoisted out of the MeshPeerRow literal: `isMuted` carries a + // defaulted UserDefaults parameter, so it cannot be passed as a + // bare function value, and inlining it makes the initializer + // expression too complex for the type checker. + let notificationsMuted = muteScope.map { ConversationNotificationMuteStore.isMuted($0) } ?? false let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false let verifiedBadge = !peer.isConnected && isVerifiedFingerprint // Vouched is subordinate to verified: never show both seals. @@ -249,7 +262,8 @@ final class PeerListModel: ObservableObject { isMutualFavorite: peer.isMutualFavorite, encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), showsVerifiedBadgeWhenOffline: verifiedBadge, - showsVouchedBadge: vouchedBadge + showsVouchedBadge: vouchedBadge, + notificationsMuted: notificationsMuted ) } diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 80d5cf29..74ca79e1 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -122,6 +122,11 @@ struct PrivateConversationHeaderState: Equatable { final class PrivateConversationModel: ObservableObject { @Published private(set) var selectedPeerID: PeerID? @Published private(set) var selectedHeaderState: PrivateConversationHeaderState? + @Published private(set) var selectedConversationNotificationsMuted = false + /// False until a handshake yields a fingerprint. Mute preferences are keyed + /// on that fingerprint, so before it exists there is nothing durable to key + /// on and the control is disabled rather than silently no-op'ing (#1606). + @Published private(set) var canToggleSelectedConversationNotificationMute = false private let chatViewModel: ChatViewModel private let conversations: ConversationStore @@ -179,6 +184,23 @@ final class PrivateConversationModel: ObservableObject { toggleFavorite(peerID: headerPeerID) } + func toggleSelectedConversationNotificationMute() { + guard + let peerID = selectedPeerID, + let scope = selectedConversationMuteScope(for: peerID) + else { return } + let next = !ConversationNotificationMuteStore.isMuted(scope) + ConversationNotificationMuteStore.setMuted(next, for: scope) + selectedConversationNotificationsMuted = next + } + + /// Mute scope for the selected DM, resolved through the same helper the + /// notification path uses so the key written and the key looked up match. + /// `nil` before a handshake: see `ConversationNotificationMuteStore.Scope.direct`. + private func selectedConversationMuteScope(for peerID: PeerID) -> ConversationNotificationMuteStore.Scope? { + .direct(fingerprint: chatViewModel.stableConversationIdentity(for: peerID)) + } + func markMessagesAsRead(from peerID: PeerID) { chatViewModel.markPrivateMessagesAsRead(from: peerID) } @@ -239,6 +261,9 @@ final class PrivateConversationModel: ObservableObject { selectedHeaderState = selectedPeerID.flatMap { peerID in makeHeaderState(for: peerID) } + let scope = selectedPeerID.flatMap { selectedConversationMuteScope(for: $0) } + canToggleSelectedConversationNotificationMute = scope != nil + selectedConversationNotificationsMuted = scope.map { ConversationNotificationMuteStore.isMuted($0) } ?? false } private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index e50d4da5..b6dea6e5 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -119,6 +119,7 @@ final class NotificationService { /// which behavior they are asserting instead of inheriting whatever the /// shared preference happens to hold when they run. private let hidePreviewsProvider: () -> Bool + private let conversationMutedProvider: (ConversationNotificationMuteStore.Scope) -> Bool private var hidePreviews: Bool { hidePreviewsProvider() @@ -136,6 +137,7 @@ final class NotificationService { private init() { self.hidePreviewsProvider = { NotificationPrivacySettings.hideMessagePreviews } + self.conversationMutedProvider = { ConversationNotificationMuteStore.isMuted($0) } self.isRunningTestsProvider = { let env = ProcessInfo.processInfo.environment return NSClassFromString("XCTestCase") != nil || @@ -161,13 +163,17 @@ final class NotificationService { authorizer: NotificationAuthorizing, requestDeliverer: NotificationRequestDelivering, categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar(), - hidePreviewsProvider: @escaping () -> Bool = { NotificationPrivacySettings.hideMessagePreviews } + hidePreviewsProvider: @escaping () -> Bool = { NotificationPrivacySettings.hideMessagePreviews }, + conversationMutedProvider: @escaping (ConversationNotificationMuteStore.Scope) -> Bool = { + ConversationNotificationMuteStore.isMuted($0) + } ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer self.categoryRegistrar = categoryRegistrar self.hidePreviewsProvider = hidePreviewsProvider + self.conversationMutedProvider = conversationMutedProvider } func requestAuthorization() { @@ -236,7 +242,20 @@ final class NotificationService { sendLocalNotification(title: title, body: body, identifier: identifier) } - func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) { + func sendPrivateMessageNotification( + from sender: String, + message: String, + peerID: PeerID, + fingerprint: String? = nil + ) { + // No fingerprint means no mute could have been keyed for this + // conversation (the toggle is disabled until one exists), so the + // notification is delivered rather than suppressed by a guess. + if let scope = ConversationNotificationMuteStore.Scope.direct(fingerprint: fingerprint), + conversationMutedProvider(scope) { + return + } + let title = hidePreviews ? Redacted.directMessageTitle : "🔒 DM from \(sender)" let body = hidePreviews ? Redacted.body : message let identifier = "private-\(UUID().uuidString)" @@ -249,6 +268,8 @@ final class NotificationService { // Geohash public chat notification with deep link to a specific geohash func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { + guard !conversationMutedProvider(.geohash(geohash)) else { return } + // The geohash itself is location data, so hiding previews withholds it // from the alert while leaving the deep link intact for the tap. let title = hidePreviews ? Redacted.geohashActivityTitle : "\(titlePrefix)\(geohash)" diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index e011c763..5e6ff7eb 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -146,6 +146,14 @@ extension ChatViewModel: ChatPrivateConversationContext { peerIDToPublicKeyFingerprint[peerID] } + /// Stable identity for keying per-conversation preferences, or nil before a + /// handshake has produced a fingerprint. Single source of truth so the mute + /// toggle, the notification lookup and the people-sheet badge cannot + /// resolve differently (#1606). + func stableConversationIdentity(for peerID: PeerID) -> String? { + (getFingerprint(for: peerID) ?? storedFingerprint(for: peerID))?.trimmedOrNilIfEmpty + } + func clearStoredFingerprint(for peerID: PeerID) { peerIdentityStore.setFingerprint(nil, for: peerID) } @@ -223,7 +231,13 @@ extension ChatViewModel: ChatPrivateConversationContext { } func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { - NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) + let fingerprint = stableConversationIdentity(for: peerID) + NotificationService.shared.sendPrivateMessageNotification( + from: senderName, + message: message, + peerID: peerID, + fingerprint: fingerprint + ) } private func makeGeohashNostrTransport() -> NostrTransport { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..cc6aa0f1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1641,6 +1641,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage MeshSightingsTracker.shared.clear() MeshEchoSettings.reset() NotificationPrivacySettings.reset() + ConversationNotificationMuteStore.reset() // A hand-added relay names an operator someone chose to route through, // which is the kind of trace a wipe should not leave behind. NostrRelaySettings.reset() diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index f857ba38..39b11ada 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -33,6 +33,7 @@ struct ContentHeaderView: View { @State private var pendingShareGeohash: String? @State private var showSharePrecisionWarning = false @State private var activeSharePayload: ChannelSharePayload? + @State private var geohashNotificationsMuted = false /// The bridged-people count belongs to the mesh channel only. private var showBridgedPeerCount: Bool { @@ -213,6 +214,19 @@ struct ContentHeaderView: View { ) if case .location(let channel) = locationChannelsModel.selectedChannel { + Button(action: { toggleGeohashNotificationMute(for: channel.geohash) }) { + Image(systemName: geohashNotificationsMuted ? "bell.slash.fill" : "bell") + .font(.bitchatSystem(size: 12)) + .foregroundColor(geohashNotificationsMuted ? palette.secondary : palette.primary) + .headerTapTarget() + } + .buttonStyle(.plain) + .accessibilityLabel( + geohashNotificationsMuted + ? String(localized: "notification.mute.geohash.unmute", defaultValue: "unmute channel notifications", comment: "Accessibility label to resume notifications for this location channel") + : String(localized: "notification.mute.geohash.mute", defaultValue: "mute channel notifications", comment: "Accessibility label to silence notifications for this location channel") + ) + Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 12)) @@ -347,9 +361,11 @@ struct ContentHeaderView: View { } .onAppear { locationChannelsModel.refreshMeshChannelsIfNeeded() + refreshGeohashNotificationMuteState() } .onChange(of: locationChannelsModel.selectedChannel) { _ in locationChannelsModel.refreshMeshChannelsIfNeeded() + refreshGeohashNotificationMuteState() } .onChange(of: locationChannelsModel.permissionState) { _ in locationChannelsModel.refreshMeshChannelsIfNeeded() @@ -465,4 +481,19 @@ private extension ContentHeaderView { return (peerListModel.reachableMeshPeerCount, color) } } + + func refreshGeohashNotificationMuteState() { + if case .location(let channel) = locationChannelsModel.selectedChannel { + geohashNotificationsMuted = ConversationNotificationMuteStore.isMuted(.geohash(channel.geohash)) + } else { + geohashNotificationsMuted = false + } + } + + func toggleGeohashNotificationMute(for geohash: String) { + let scope = ConversationNotificationMuteStore.Scope.geohash(geohash) + let next = !ConversationNotificationMuteStore.isMuted(scope) + ConversationNotificationMuteStore.setMuted(next, for: scope) + geohashNotificationsMuted = next + } } diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 88be040f..555ffdc6 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -533,6 +533,31 @@ private struct ContentPrivateChatSheetView: View { : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") ) } + + Button(action: { + privateConversationModel.toggleSelectedConversationNotificationMute() + }) { + Image(systemName: privateConversationModel.selectedConversationNotificationsMuted ? "bell.slash.fill" : "bell") + .font(.bitchatSystem(size: 14)) + .foregroundColor( + privateConversationModel.selectedConversationNotificationsMuted + ? palette.secondary + : palette.primary + ) + .frame(width: 32, height: 32) + .contentShape(Rectangle().inset(by: -6)) + } + .buttonStyle(.plain) + // Disabled until a handshake produces the fingerprint the + // mute is keyed on, so the control can never appear to + // work while writing a key nothing reads. + .disabled(!privateConversationModel.canToggleSelectedConversationNotificationMute) + .opacity(privateConversationModel.canToggleSelectedConversationNotificationMute ? 1 : 0.4) + .accessibilityLabel( + privateConversationModel.selectedConversationNotificationsMuted + ? String(localized: "notification.mute.direct.unmute", defaultValue: "unmute notifications", comment: "Accessibility label to resume notifications for this direct conversation") + : String(localized: "notification.mute.direct.mute", defaultValue: "mute notifications", comment: "Accessibility label to silence notifications for this direct conversation") + ) } .frame(maxWidth: .infinity) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d94af5f..a0f147eb 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -34,6 +34,7 @@ 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 notificationsMutedLabel = String(localized: "notification.mute.state.muted", comment: "Badge on a peer row indicating that conversation's notifications are muted") } var body: some View { @@ -163,6 +164,14 @@ struct MeshPeerList: View { .help(Strings.newMessagesTooltip) } + if peer.notificationsMuted { + Image(systemName: "bell.slash.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.secondary) + .accessibilityLabel(Strings.notificationsMutedLabel) + .help(Strings.notificationsMutedLabel) + } + if !isMe { Button(action: { onToggleFavorite(peer.peerID) }) { Image(systemName: peer.isFavorite ? "star.fill" : "star") From bef6f0106f589a79c6e1e80d5425b7d6847ebe41 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 29 Aug 2026 16:50:07 +0530 Subject: [PATCH 3/6] Cover mute store keys and notification skip paths --- ...nversationNotificationMuteStoreTests.swift | 75 +++++++++++++++++++ .../Services/NotificationServiceTests.swift | 51 +++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 bitchatTests/Services/ConversationNotificationMuteStoreTests.swift diff --git a/bitchatTests/Services/ConversationNotificationMuteStoreTests.swift b/bitchatTests/Services/ConversationNotificationMuteStoreTests.swift new file mode 100644 index 00000000..e8545c5d --- /dev/null +++ b/bitchatTests/Services/ConversationNotificationMuteStoreTests.swift @@ -0,0 +1,75 @@ +// +// ConversationNotificationMuteStoreTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct ConversationNotificationMuteStoreTests { + private func isolatedDefaults() -> UserDefaults { + let suite = "ConversationNotificationMuteStoreTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + @Test func directMuteKeysOnTheFingerprint() throws { + let defaults = isolatedDefaults() + let scope = try #require(ConversationNotificationMuteStore.Scope.direct(fingerprint: "ABCD1234")) + ConversationNotificationMuteStore.setMuted(true, for: scope, in: defaults) + #expect(ConversationNotificationMuteStore.isMuted(scope, in: defaults)) + #expect(ConversationNotificationMuteStore.mutedKeys(in: defaults) == ["dm:abcd1234"]) + } + + /// There is no routing-peer-ID fallback on purpose: peer IDs rotate, so a + /// mute keyed on one would silently stop applying while the UI still + /// showed the conversation muted. + @Test func directMuteIsUnavailableWithoutAFingerprint() { + #expect(ConversationNotificationMuteStore.Scope.direct(fingerprint: nil) == nil) + #expect(ConversationNotificationMuteStore.Scope.direct(fingerprint: "") == nil) + #expect(ConversationNotificationMuteStore.Scope.direct(fingerprint: " ") == nil) + } + + /// The bug this replaced: writing `dm:` on the toggle side and + /// looking up `dm:` on the notification side left the mute silently + /// ineffective. One resolver means one key. + @Test func aFingerprintKeyIsNeverConfusedWithAPeerIDKey() throws { + let defaults = isolatedDefaults() + let byFingerprint = try #require(ConversationNotificationMuteStore.Scope.direct(fingerprint: "ABCD1234")) + ConversationNotificationMuteStore.setMuted(true, for: byFingerprint, in: defaults) + // A scope built from what used to be the peer-ID fallback must not read + // as muted. + let byPeerID = try #require(ConversationNotificationMuteStore.Scope.direct(fingerprint: "deadbeef")) + #expect(!ConversationNotificationMuteStore.isMuted(byPeerID, in: defaults)) + } + + @Test func geohashMuteIsCaseInsensitive() { + let defaults = isolatedDefaults() + let scope = ConversationNotificationMuteStore.Scope.geohash("U4PRUY") + ConversationNotificationMuteStore.setMuted(true, for: scope, in: defaults) + #expect( + ConversationNotificationMuteStore.isMuted( + .geohash("u4pruy"), + in: defaults + ) + ) + } + + @Test func unmuteRemovesKeyAndResetClearsAll() throws { + let defaults = isolatedDefaults() + let dm = try #require(ConversationNotificationMuteStore.Scope.direct(fingerprint: "fp")) + let geo = ConversationNotificationMuteStore.Scope.geohash("u4pru") + ConversationNotificationMuteStore.setMuted(true, for: dm, in: defaults) + ConversationNotificationMuteStore.setMuted(true, for: geo, in: defaults) + ConversationNotificationMuteStore.setMuted(false, for: dm, in: defaults) + #expect(!ConversationNotificationMuteStore.isMuted(dm, in: defaults)) + #expect(ConversationNotificationMuteStore.isMuted(geo, in: defaults)) + ConversationNotificationMuteStore.reset(in: defaults) + #expect(ConversationNotificationMuteStore.mutedKeys(in: defaults).isEmpty) + } +} diff --git a/bitchatTests/Services/NotificationServiceTests.swift b/bitchatTests/Services/NotificationServiceTests.swift index 384f0075..02452089 100644 --- a/bitchatTests/Services/NotificationServiceTests.swift +++ b/bitchatTests/Services/NotificationServiceTests.swift @@ -116,6 +116,57 @@ final class NotificationServiceTests: XCTestCase { XCTAssertEqual(deliverer.requests[1].content.interruptionLevel, .timeSensitive) XCTAssertEqual(deliverer.requests[1].content.body, "2 people around") } + + func test_sendPrivateMessageNotification_skipsWhenConversationMuted() { + let deliverer = RecordingNotificationRequestDeliverer() + let peerID = PeerID(str: "deadbeefdeadbeef") + let scope = ConversationNotificationMuteStore.Scope.direct(fingerprint: "fp1") + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer, + conversationMutedProvider: { $0 == scope } + ) + + service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID, fingerprint: "fp1") + + XCTAssertTrue(deliverer.requests.isEmpty) + } + + /// No fingerprint means no mute can have been keyed (the toggle is disabled + /// until one exists), so the notification must still be delivered rather + /// than suppressed by a peer-ID guess that rotation would invalidate. + func test_sendPrivateMessageNotification_deliversWhenNoFingerprintIsKnown() { + let deliverer = RecordingNotificationRequestDeliverer() + let peerID = PeerID(str: "deadbeefdeadbeef") + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer, + conversationMutedProvider: { _ in true } + ) + + service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID, fingerprint: nil) + + XCTAssertEqual(deliverer.requests.count, 1) + } + + func test_sendGeohashActivityNotification_skipsWhenChannelMuted() { + let deliverer = RecordingNotificationRequestDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer, + conversationMutedProvider: { + if case .geohash("u4pru") = $0 { return true } + return false + } + ) + + service.sendGeohashActivityNotification(geohash: "u4pru", bodyPreview: "hello") + + XCTAssertTrue(deliverer.requests.isEmpty) + } } private final class RecordingNotificationAuthorizer: NotificationAuthorizing { From f67cdf56fde5e31f3ad056b0c14c51f532d9b354 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 29 Aug 2026 16:50:07 +0530 Subject: [PATCH 4/6] Add notification.mute keys surgically without catalog rewrite English is marked translated; other locales are state new with the source string so coverage stays green without fake translations. --- bitchat/Localizable.xcstrings | 925 ++++++++++++++++++++++++++++++++++ 1 file changed, 925 insertions(+) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..bb91f5f8 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -19195,6 +19195,931 @@ } } }, + "notification.mute.direct.mute" : { + "comment" : "Accessibility label to silence notifications for this direct conversation", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "bn" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mute notifications" + } + }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "fa" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "fil" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "he" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "hi" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "id" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "it" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ms" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ne" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "pl" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "pt" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "sv" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ta" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "th" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "tr" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "uk" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "ur" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "vi" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "new", + "value" : "mute notifications" + } + } + } + }, + "notification.mute.direct.unmute" : { + "comment" : "Accessibility label to resume notifications for this direct conversation", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "bn" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unmute notifications" + } + }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "fa" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "fil" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "he" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "hi" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "id" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "it" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ms" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ne" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "pl" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "pt" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "sv" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ta" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "th" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "tr" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "uk" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "ur" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "vi" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute notifications" + } + } + } + }, + "notification.mute.geohash.mute" : { + "comment" : "Accessibility label to silence notifications for this location channel", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "bn" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mute channel notifications" + } + }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "fa" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "fil" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "he" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "hi" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "id" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "it" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ms" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ne" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "pl" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "pt" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "sv" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ta" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "th" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "tr" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "uk" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "ur" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "vi" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "new", + "value" : "mute channel notifications" + } + } + } + }, + "notification.mute.geohash.unmute" : { + "comment" : "Accessibility label to resume notifications for this location channel", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "bn" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unmute channel notifications" + } + }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "fa" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "fil" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "he" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "hi" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "id" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "it" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ja" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ms" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ne" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "pl" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "pt" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "sv" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ta" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "th" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "tr" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "uk" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "ur" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "vi" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "new", + "value" : "unmute channel notifications" + } + } + } + }, + "notification.mute.state.muted" : { + "comment" : "Badge on a peer row indicating that conversation's notifications are muted", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "bn" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifications muted" + } + }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "fa" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "fil" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "fr" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "he" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "hi" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "id" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "it" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ja" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ms" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ne" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "pl" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "pt" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ru" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "sv" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ta" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "th" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "tr" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "uk" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "ur" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "vi" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "new", + "value" : "notifications muted" + } + } + } + }, "#%@" : { "comment" : "Non-localizable channel hashtag format used in code", "extractionState" : "manual", From f5427acf1c26ed2178d6263207e45842e1b9200c Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 29 Aug 2026 16:50:07 +0530 Subject: [PATCH 5/6] Document per-conversation notification mute behavior --- docs/CONVERSATION-NOTIFICATION-MUTE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/CONVERSATION-NOTIFICATION-MUTE.md diff --git a/docs/CONVERSATION-NOTIFICATION-MUTE.md b/docs/CONVERSATION-NOTIFICATION-MUTE.md new file mode 100644 index 00000000..e1e9320f --- /dev/null +++ b/docs/CONVERSATION-NOTIFICATION-MUTE.md @@ -0,0 +1,22 @@ +# Per-conversation notification mute + +Issue: [#1594](https://github.com/permissionlesstech/bitchat/issues/1594) + +## Behavior + +- Direct conversations and geohash channels can be muted independently. +- Mute affects **local notifications only** — messages still arrive, store, and show unread state. +- DM mute keys are the peer fingerprint; geohash keys are the lowercase geohash. +- There is deliberately no routing-peer-ID fallback for DMs: peer IDs rotate, so a mute keyed on one would silently stop applying. Until a handshake yields a fingerprint the toggle is disabled rather than writing a key that expires. +- The toggle, the notification lookup, and the people-sheet badge all resolve the fingerprint through `stableConversationIdentity(for:)`, so the key written and the key read cannot disagree. +- Panic wipe clears all mute preferences (`ConversationNotificationMuteStore.reset()`). + +## UI + +- DM sheet header: bell / bell.slash toggle next to favorites. +- Location channel header: same toggle beside bookmark and share. +- Mesh people sheet: a `bell.slash` badge on rows whose direct conversation is muted, so the state is visible without opening the thread. + +## Implementation + +`ConversationNotificationMuteStore` persists muted scope keys in `UserDefaults`. `NotificationService` consults the store before posting DM or geohash activity alerts. From 06e3ebb1d8c1aeded5392217496f4f88a9a4e545 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 1 Sep 2026 00:04:12 +0530 Subject: [PATCH 6/6] fix(l10n): translate conversation mute menu strings non-en locales still carried english paste for the five mute keys. --- bitchat/Localizable.xcstrings | 580 +++++++++++++++++----------------- 1 file changed, 290 insertions(+), 290 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index bb91f5f8..07526ee6 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -19200,20 +19200,20 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "كتم الإشعارات" } }, "bn" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "বিজ্ঞপ্তি নিঃশব্দ করো" } }, "de" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "benachrichtigungen stummschalten" } }, "en" : { @@ -19224,158 +19224,158 @@ }, "es" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "silenciar notificaciones" } }, "fa" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "بی‌صدا کردن اعلان‌ها" } }, "fil" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "i-mute ang mga notification" } }, "fr" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "couper les notifications" } }, "he" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "השתק התראות" } }, "hi" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "सूचनाएँ म्यूट करो" } }, "id" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "bisukan notifikasi" } }, "it" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "silenzia le notifiche" } }, "ja" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "通知をミュート" } }, "ko" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "알림 뮤트" } }, "ms" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "senyapkan pemberitahuan" } }, "ne" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "सूचना म्युट गर" } }, "nl" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "meldingen dempen" } }, "pl" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "wycisz powiadomienia" } }, "pt" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "silenciar notificações" } }, "pt-BR" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "silenciar notificações" } }, "ru" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "выключить уведомления" } }, "sv" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "tysta aviseringar" } }, "ta" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "அறிவிப்புகளை முடக்கு" } }, "th" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "ปิดการแจ้งเตือน" } }, "tr" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "bildirimleri sessize al" } }, "uk" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "вимкнути сповіщення" } }, "ur" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "اطلاعات خاموش کریں" } }, "vi" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "tắt thông báo" } }, "zh-Hans" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "静音通知" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", - "value" : "mute notifications" + "state" : "translated", + "value" : "靜音通知" } } } @@ -19385,20 +19385,20 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "إلغاء كتم الإشعارات" } }, "bn" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "বিজ্ঞপ্তি আনমিউট করো" } }, "de" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "benachrichtigungen wieder an" } }, "en" : { @@ -19409,158 +19409,158 @@ }, "es" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "activar notificaciones" } }, "fa" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "وصل کردن اعلان‌ها" } }, "fil" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "i-unmute ang mga notification" } }, "fr" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "réactiver les notifications" } }, "he" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "בטל השתקת התראות" } }, "hi" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "सूचनाएँ अनम्यूट करो" } }, "id" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "nyalakan notifikasi" } }, "it" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "riattiva le notifiche" } }, "ja" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "通知のミュートを解除" } }, "ko" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "알림 뮤트 해제" } }, "ms" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "nyahsenyap pemberitahuan" } }, "ne" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "सूचना अनम्युट गर" } }, "nl" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "meldingen aanzetten" } }, "pl" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "włącz powiadomienia" } }, "pt" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "reativar notificações" } }, "pt-BR" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "reativar notificações" } }, "ru" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "включить уведомления" } }, "sv" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "slå på aviseringar" } }, "ta" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "அறிவிப்பு முடக்கம் நீக்கு" } }, "th" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "เปิดการแจ้งเตือน" } }, "tr" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "bildirimleri aç" } }, "uk" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "увімкнути сповіщення" } }, "ur" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "اطلاعات آن کریں" } }, "vi" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "bật thông báo" } }, "zh-Hans" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "取消静音通知" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", - "value" : "unmute notifications" + "state" : "translated", + "value" : "取消靜音通知" } } } @@ -19570,20 +19570,20 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "كتم إشعارات القناة" } }, "bn" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "চ্যানেল বিজ্ঞপ্তি নিঃশব্দ করো" } }, "de" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "kanal-benachrichtigungen stummschalten" } }, "en" : { @@ -19594,158 +19594,158 @@ }, "es" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "silenciar notificaciones del canal" } }, "fa" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "بی‌صدا کردن اعلان‌های کانال" } }, "fil" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "i-mute ang notification ng channel" } }, "fr" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "couper les notifications du canal" } }, "he" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "השתק התראות ערוץ" } }, "hi" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "चैनल सूचनाएँ म्यूट करो" } }, "id" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "bisukan notifikasi kanal" } }, "it" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "silenzia le notifiche del canale" } }, "ja" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "チャンネル通知をミュート" } }, "ko" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "채널 알림 뮤트" } }, "ms" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "senyapkan pemberitahuan saluran" } }, "ne" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "च्यानल सूचना म्युट गर" } }, "nl" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "kanaalmeldingen dempen" } }, "pl" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "wycisz powiadomienia kanału" } }, "pt" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "silenciar notificações do canal" } }, "pt-BR" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "silenciar notificações do canal" } }, "ru" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "выключить уведомления канала" } }, "sv" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "tysta kanalaviseringar" } }, "ta" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "சேனல் அறிவிப்புகளை முடக்கு" } }, "th" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "ปิดการแจ้งเตือนช่อง" } }, "tr" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "kanal bildirimlerini sessize al" } }, "uk" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "вимкнути сповіщення каналу" } }, "ur" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "چینل اطلاعات خاموش کریں" } }, "vi" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "tắt thông báo kênh" } }, "zh-Hans" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "静音频道通知" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", - "value" : "mute channel notifications" + "state" : "translated", + "value" : "靜音頻道通知" } } } @@ -19755,20 +19755,20 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "إلغاء كتم إشعارات القناة" } }, "bn" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "চ্যানেল বিজ্ঞপ্তি আনমিউট করো" } }, "de" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "kanal-benachrichtigungen wieder an" } }, "en" : { @@ -19779,158 +19779,158 @@ }, "es" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "activar notificaciones del canal" } }, "fa" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "وصل کردن اعلان‌های کانال" } }, "fil" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "i-unmute ang notification ng channel" } }, "fr" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "réactiver les notifications du canal" } }, "he" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "בטל השתקת התראות ערוץ" } }, "hi" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "चैनل सूचनाएँ अनम्यूट करो" } }, "id" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "nyalakan notifikasi kanal" } }, "it" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "riattiva le notifiche del canale" } }, "ja" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "チャンネル通知のミュートを解除" } }, "ko" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "채널 알림 뮤트 해제" } }, "ms" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "nyahsenyap pemberitahuan saluran" } }, "ne" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "च्यानल सूचना अनम्युट गर" } }, "nl" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "kanaalmeldingen aanzetten" } }, "pl" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "włącz powiadomienia kanału" } }, "pt" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "reativar notificações do canal" } }, "pt-BR" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "reativar notificações do canal" } }, "ru" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "включить уведомления канала" } }, "sv" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "slå på kanalaviseringar" } }, "ta" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "சேனல் அறிவிப்பு முடக்கம் நீக்கு" } }, "th" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "เปิดการแจ้งเตือนช่อง" } }, "tr" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "kanal bildirimlerini aç" } }, "uk" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "увімкнути сповіщення каналу" } }, "ur" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "چینل اطلاعات آن کریں" } }, "vi" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "bật thông báo kênh" } }, "zh-Hans" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "取消静音频道通知" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", - "value" : "unmute channel notifications" + "state" : "translated", + "value" : "取消靜音頻道通知" } } } @@ -19940,20 +19940,20 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "مكتوم" } }, "bn" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "নিঃশব্দ" } }, "de" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "stumm" } }, "en" : { @@ -19964,158 +19964,158 @@ }, "es" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "silenciado" } }, "fa" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "بی‌صدا" } }, "fil" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "naka-mute" } }, "fr" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "en sourdine" } }, "he" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "מושתק" } }, "hi" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "म्यूट" } }, "id" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "dibisukan" } }, "it" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "silenziato" } }, "ja" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "ミュート中" } }, "ko" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "뮤트됨" } }, "ms" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "disenyapkan" } }, "ne" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "म्युट" } }, "nl" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "gedempt" } }, "pl" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "wyciszone" } }, "pt" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "silenciado" } }, "pt-BR" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "silenciado" } }, "ru" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "без звука" } }, "sv" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "tystad" } }, "ta" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "முடக்கப்பட்டது" } }, "th" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "ปิดเสียงแล้ว" } }, "tr" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "sessiz" } }, "uk" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "без звуку" } }, "ur" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "خاموش" } }, "vi" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "đã tắt" } }, "zh-Hans" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "已静音" } }, "zh-Hant" : { "stringUnit" : { - "state" : "new", - "value" : "notifications muted" + "state" : "translated", + "value" : "已靜音" } } }