From 1387dbc848b43c3092d8b3b37912ce84e2889755 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 29 Aug 2026 16:50:05 +0530 Subject: [PATCH] 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) + } +}