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.
This commit is contained in:
Taksh 2026-08-29 16:50:06 +05:30
parent 1387dbc848
commit e7051a431d
8 changed files with 144 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

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