mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
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:
parent
1387dbc848
commit
e7051a431d
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user