Compare commits

...

6 Commits

Author SHA1 Message Date
Taksh
06e3ebb1d8 fix(l10n): translate conversation mute menu strings
non-en locales still carried english paste for the five mute keys.
2026-09-01 00:04:12 +05:30
Taksh
f5427acf1c Document per-conversation notification mute behavior 2026-08-29 16:50:07 +05:30
Taksh
f67cdf56fd 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.
2026-08-29 16:50:07 +05:30
Taksh
bef6f0106f Cover mute store keys and notification skip paths 2026-08-29 16:50:07 +05:30
Taksh
e7051a431d 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.
2026-08-29 16:50:06 +05:30
Taksh
1387dbc848 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.
2026-08-29 16:50:05 +05:30
13 changed files with 1289 additions and 4 deletions

View File

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

View File

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

View File

@ -19195,6 +19195,931 @@
}
}
},
"notification.mute.direct.mute" : {
"comment" : "Accessibility label to silence notifications for this direct conversation",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "كتم الإشعارات"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "বিজ্ঞপ্তি নিঃশব্দ করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "benachrichtigungen stummschalten"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mute notifications"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciar notificaciones"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "بی‌صدا کردن اعلان‌ها"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "i-mute ang mga notification"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "couper les notifications"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "השתק התראות"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सूचनाएँ म्यूट करो"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "bisukan notifikasi"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenzia le notifiche"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "通知をミュート"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "알림 뮤트"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "senyapkan pemberitahuan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सूचना म्युट गर"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "meldingen dempen"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "wycisz powiadomienia"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciar notificações"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciar notificações"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "выключить уведомления"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "tysta aviseringar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "அறிவிப்புகளை முடக்கு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ปิดการแจ้งเตือน"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bildirimleri sessize al"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "вимкнути сповіщення"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "اطلاعات خاموش کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tắt thông báo"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "静音通知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "靜音通知"
}
}
}
},
"notification.mute.direct.unmute" : {
"comment" : "Accessibility label to resume notifications for this direct conversation",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "إلغاء كتم الإشعارات"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "বিজ্ঞপ্তি আনমিউট করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "benachrichtigungen wieder an"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "unmute notifications"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "activar notificaciones"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "وصل کردن اعلان‌ها"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "i-unmute ang mga notification"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "réactiver les notifications"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "בטל השתקת התראות"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सूचनाएँ अनम्यूट करो"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "nyalakan notifikasi"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "riattiva le notifiche"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "通知のミュートを解除"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "알림 뮤트 해제"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "nyahsenyap pemberitahuan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सूचना अनम्युट गर"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "meldingen aanzetten"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "włącz powiadomienia"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "reativar notificações"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "reativar notificações"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "включить уведомления"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "slå på aviseringar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "அறிவிப்பு முடக்கம் நீக்கு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เปิดการแจ้งเตือน"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bildirimleri aç"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "увімкнути сповіщення"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "اطلاعات آن کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "bật thông báo"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "取消静音通知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "取消靜音通知"
}
}
}
},
"notification.mute.geohash.mute" : {
"comment" : "Accessibility label to silence notifications for this location channel",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "كتم إشعارات القناة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "চ্যানেল বিজ্ঞপ্তি নিঃশব্দ করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "kanal-benachrichtigungen stummschalten"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "mute channel notifications"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciar notificaciones del canal"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "بی‌صدا کردن اعلان‌های کانال"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "i-mute ang notification ng channel"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "couper les notifications du canal"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "השתק התראות ערוץ"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "चैनल सूचनाएँ म्यूट करो"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "bisukan notifikasi kanal"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenzia le notifiche del canale"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "チャンネル通知をミュート"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "채널 알림 뮤트"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "senyapkan pemberitahuan saluran"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "च्यानल सूचना म्युट गर"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "kanaalmeldingen dempen"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "wycisz powiadomienia kanału"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciar notificações do canal"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciar notificações do canal"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "выключить уведомления канала"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "tysta kanalaviseringar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "சேனல் அறிவிப்புகளை முடக்கு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ปิดการแจ้งเตือนช่อง"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "kanal bildirimlerini sessize al"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "вимкнути сповіщення каналу"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "چینل اطلاعات خاموش کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tắt thông báo kênh"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "静音频道通知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "靜音頻道通知"
}
}
}
},
"notification.mute.geohash.unmute" : {
"comment" : "Accessibility label to resume notifications for this location channel",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "إلغاء كتم إشعارات القناة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "চ্যানেল বিজ্ঞপ্তি আনমিউট করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "kanal-benachrichtigungen wieder an"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "unmute channel notifications"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "activar notificaciones del canal"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "وصل کردن اعلان‌های کانال"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "i-unmute ang notification ng channel"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "réactiver les notifications du canal"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "בטל השתקת התראות ערוץ"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "चैनل सूचनाएँ अनम्यूट करो"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "nyalakan notifikasi kanal"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "riattiva le notifiche del canale"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "チャンネル通知のミュートを解除"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "채널 알림 뮤트 해제"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "nyahsenyap pemberitahuan saluran"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "च्यानल सूचना अनम्युट गर"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "kanaalmeldingen aanzetten"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "włącz powiadomienia kanału"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "reativar notificações do canal"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "reativar notificações do canal"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "включить уведомления канала"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "slå på kanalaviseringar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "சேனல் அறிவிப்பு முடக்கம் நீக்கு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เปิดการแจ้งเตือนช่อง"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "kanal bildirimlerini aç"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "увімкнути сповіщення каналу"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "چینل اطلاعات آن کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "bật thông báo kênh"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "取消静音频道通知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "取消靜音頻道通知"
}
}
}
},
"notification.mute.state.muted" : {
"comment" : "Badge on a peer row indicating that conversation's notifications are muted",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "مكتوم"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "নিঃশব্দ"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "stumm"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "notifications muted"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciado"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "بی‌صدا"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "naka-mute"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "en sourdine"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מושתק"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "म्यूट"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "dibisukan"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenziato"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ミュート中"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "뮤트됨"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "disenyapkan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "म्युट"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "gedempt"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "wyciszone"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciado"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "silenciado"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "без звука"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "tystad"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "முடக்கப்பட்டது"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ปิดเสียงแล้ว"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "sessiz"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "без звуку"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "خاموش"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "đã tắt"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已静音"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "已靜音"
}
}
}
},
"#%@" : {
"comment" : "Non-localizable channel hashtag format used in code",
"extractionState" : "manual",

View File

@ -0,0 +1,72 @@
//
// ConversationNotificationMuteStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<String> {
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,75 @@
//
// ConversationNotificationMuteStoreTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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:<fingerprint>` on the toggle side and
/// looking up `dm:<peerid>` 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)
}
}

View File

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

View File

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