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