mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
Compare commits
6 Commits
5cec60a97d
...
06e3ebb1d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e3ebb1d8 | ||
|
|
f5427acf1c | ||
|
|
f67cdf56fd | ||
|
|
bef6f0106f | ||
|
|
e7051a431d | ||
|
|
1387dbc848 |
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
72
bitchat/Services/ConversationNotificationMuteStore.swift
Normal file
72
bitchat/Services/ConversationNotificationMuteStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
22
docs/CONVERSATION-NOTIFICATION-MUTE.md
Normal file
22
docs/CONVERSATION-NOTIFICATION-MUTE.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user