mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Merge db0d00f4db3a59c0310f9582c46ed781404cfd66 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
2697df575a
@ -8,6 +8,7 @@ struct MeshPeerRow: Identifiable, Equatable {
|
||||
let isMe: Bool
|
||||
let hasUnread: Bool
|
||||
let isBlocked: Bool
|
||||
let isNearbyNotificationMuted: Bool
|
||||
let isFavorite: Bool
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
@ -95,6 +96,15 @@ final class PeerListModel: ObservableObject {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
}
|
||||
|
||||
func toggleNearbyNotificationMute(peerID: PeerID) {
|
||||
let muted = !chatViewModel.isNearbyNotificationMuted(for: peerID)
|
||||
chatViewModel.setNearbyNotificationMuted(for: peerID, muted: muted)
|
||||
// Mute lives on the identity manager — PeerListModel's publishers
|
||||
// do not observe it, so refresh now or the row's bell / menu label
|
||||
// stay stale until some unrelated peer update (#1541 Codex).
|
||||
refresh()
|
||||
}
|
||||
|
||||
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||
}
|
||||
@ -217,6 +227,7 @@ final class PeerListModel: ObservableObject {
|
||||
isMe: isMe,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||
isNearbyNotificationMuted: !isMe && chatViewModel.isNearbyNotificationMuted(for: peer.peerID),
|
||||
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||
isConnected: peer.isConnected,
|
||||
isReachable: peer.isReachable,
|
||||
@ -248,7 +259,7 @@ final class PeerListModel: ObservableObject {
|
||||
self.groupRows = groupRows
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)-\($0.isNearbyNotificationMuted)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
|
||||
@ -187,6 +187,11 @@ struct IdentityCache: Codable {
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Noise fingerprints muted for the "bitchatters nearby" local
|
||||
// notification. Optional so caches written before this feature decode
|
||||
// cleanly. Local-only; never transmitted.
|
||||
var nearbyNotificationMutedFingerprints: Set<String>? = nil
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — decodeIfPresent
|
||||
// is used below, and a missing key must not trip the "unreadable cache"
|
||||
@ -237,6 +242,7 @@ struct IdentityCache: Codable {
|
||||
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
|
||||
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
|
||||
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
|
||||
nearbyNotificationMutedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .nearbyNotificationMutedFingerprints)
|
||||
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
|
||||
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
|
||||
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
|
||||
|
||||
@ -113,6 +113,14 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
// MARK: Blocked Users Management
|
||||
func isBlocked(fingerprint: String) -> Bool
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool)
|
||||
|
||||
// MARK: Nearby-notification mute
|
||||
/// Explicit proximity mute (does not include blocked peers).
|
||||
func isNearbyNotificationMuted(fingerprint: String) -> Bool
|
||||
/// True when this fingerprint should not count toward nearby alerts
|
||||
/// (explicit mute or blocked).
|
||||
func suppressesNearbyNotification(fingerprint: String) -> Bool
|
||||
func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool)
|
||||
|
||||
// MARK: Geohash (Nostr) Blocking
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
@ -602,6 +610,42 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nearby-notification mute
|
||||
|
||||
func isNearbyNotificationMuted(fingerprint: String) -> Bool {
|
||||
queue.sync {
|
||||
cache.nearbyNotificationMutedFingerprints?.contains(fingerprint) == true
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this fingerprint should not contribute to the "bitchatters
|
||||
/// nearby" local notification. Blocked peers are always suppressed;
|
||||
/// additionally, peers the user has explicitly muted for proximity
|
||||
/// alerts (typically their own other devices) are suppressed without a
|
||||
/// full block.
|
||||
func suppressesNearbyNotification(fingerprint: String) -> Bool {
|
||||
queue.sync {
|
||||
if cache.socialIdentities[fingerprint]?.isBlocked == true {
|
||||
return true
|
||||
}
|
||||
return cache.nearbyNotificationMutedFingerprints?.contains(fingerprint) == true
|
||||
}
|
||||
}
|
||||
|
||||
func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) {
|
||||
guard !fingerprint.isEmpty else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
var mutedSet = self.cache.nearbyNotificationMutedFingerprints ?? []
|
||||
if muted {
|
||||
mutedSet.insert(fingerprint)
|
||||
} else {
|
||||
mutedSet.remove(fingerprint)
|
||||
}
|
||||
self.cache.nearbyNotificationMutedFingerprints = mutedSet
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash (Nostr) Blocking
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
|
||||
@ -62336,6 +62336,378 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.action.mute_nearby" : {
|
||||
"comment" : "Context menu action to stop nearby notifications for this peer",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "كتم تنبيهات القرب"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "কাছাকাছি সতর্কতা নিঃশব্দ"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nahe benachrichtigungen stummschalten"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mute nearby alerts"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "silenciar alertas cercanas"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "بیصدا کردن هشدارهای نزدیک"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "i-mute ang nearby alerts"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "couper les alertes de proximité"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "השתק התראות קרובות"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "नज़दीकी अलर्ट म्यूट करें"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "bisukan peringatan terdekat"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "silenzia avvisi vicini"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "近くの通知をミュート"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "근처 알림 음소거"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "redam amaran berdekatan"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "नजिकका अलर्ट म्यूट गर्नुहोस्"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nabije meldingen dempen"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "wycisz alerty w pobliżu"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "silenciar alertas próximos"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "silenciar alertas próximos"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "отключить уведомления о рядом"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "tysta närhetsaviseringar"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "அருகிலுள்ள எச்சரிக்கைகளை முடக்கு"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ปิดเสียงการแจ้งเตือนใกล้เคียง"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "yakındaki uyarıları sessize al"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "вимкнути сповіщення про близькість"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "قریبی الرٹس خاموش کریں"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "tắt cảnh báo gần"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "静音附近提醒"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "靜音附近提醒"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.action.unmute_nearby" : {
|
||||
"comment" : "Context menu action to resume nearby notifications for this peer",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "إلغاء كتم تنبيهات القرب"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "কাছাকাছি সতর্কতা আনমিউট"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nahe benachrichtigungen wieder an"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Unmute nearby alerts"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "reactivar alertas cercanas"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "صدادار کردن هشدارهای نزدیک"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "i-unmute ang nearby alerts"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "rétablir les alertes de proximité"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "בטל השתקת התראות קרובות"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "नज़दीकी अलर्ट अनम्यूट करें"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "bunyikan lagi peringatan terdekat"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "riattiva avvisi vicini"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "近くの通知のミュート解除"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "근처 알림 음소거 해제"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nyahredam amaran berdekatan"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "नजिकका अलर्ट अनम्यूट गर्नुहोस्"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nabije meldingen dempen opheffen"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "przywróć alerty w pobliżu"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "reativar alertas próximos"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "reativar alertas próximos"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "включить уведомления о рядом"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "slå på närhetsaviseringar"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "அருகிலுள்ள எச்சரிக்கைகளை இயக்கு"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "เปิดเสียงการแจ้งเตือนใกล้เคียง"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "yakındaki uyarıların sesini aç"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "увімкнути сповіщення про близькість"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "قریبی الرٹس بحال کریں"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "bật lại cảnh báo gần"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "取消静音附近提醒"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "取消靜音附近提醒"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.state.blocked" : {
|
||||
"comment" : "State label for a blocked peer",
|
||||
"extractionState" : "manual",
|
||||
@ -62708,6 +63080,192 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.state.nearby_muted" : {
|
||||
"comment" : "State label for a peer muted for proximity notifications",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "تنبيهات القرب مكتومة"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "কাছাকাছি সতর্কতা নিঃশব্দ"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nahe benachrichtigungen stumm"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nearby alerts muted"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "alertas cercanas silenciadas"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "هشدارهای نزدیک بیصدا"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "naka-mute ang nearby alerts"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "alertes de proximité coupées"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "התראות קרובות מושתקות"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "नज़दीकी अलर्ट म्यूट"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "peringatan terdekat dibisukan"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "avvisi vicini silenziati"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "近くの通知をミュート中"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "근처 알림 음소거됨"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "amaran berdekatan diredam"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "नजिकका अलर्ट म्यूट"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "nabije meldingen gedempt"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "alerty w pobliżu wyciszone"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "alertas próximos silenciados"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "alertas próximos silenciados"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "уведомления о рядом отключены"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "närhetsaviseringar tystade"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "அருகிலுள்ள எச்சரிக்கைகள் முடக்கப்பட்டன"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ปิดเสียงการแจ้งเตือนใกล้เคียงแล้ว"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "yakındaki uyarılar sessiz"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "сповіщення про близькість вимкнено"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "قریبی الرٹس خاموش"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "đã tắt cảnh báo gần"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已静音附近提醒"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "已靜音附近提醒"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mesh_peers.state.offline" : {
|
||||
"comment" : "State label for a peer that is not currently reachable",
|
||||
"extractionState" : "manual",
|
||||
|
||||
@ -37,6 +37,11 @@ protocol ChatPeerListContext: AnyObject {
|
||||
/// Posts the "bitchatters nearby" local notification.
|
||||
func notifyNetworkAvailable(peerCount: Int)
|
||||
|
||||
/// True when this peer should not count toward the nearby-notification
|
||||
/// empty→populated transition (blocked peers, or peers the user muted
|
||||
/// for proximity alerts — typically their own other devices).
|
||||
func suppressesNearbyNotification(for peerID: PeerID) -> Bool
|
||||
|
||||
/// Records peers seen within range for the daily ambient sightings tally.
|
||||
func recordMeshSightings(peerIDs: [PeerID])
|
||||
}
|
||||
@ -52,10 +57,13 @@ extension ChatViewModel: ChatPeerListContext {
|
||||
// call.
|
||||
|
||||
func activeMeshPeerCount() -> Int {
|
||||
// Exclude blocked / proximity-muted peers so a muted-only mesh
|
||||
// still counts as empty for the notification reset timers.
|
||||
meshService
|
||||
.currentPeerSnapshots()
|
||||
.filter { snapshot in
|
||||
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
|
||||
(snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID))
|
||||
&& !suppressesNearbyNotification(for: snapshot.peerID)
|
||||
}
|
||||
.count
|
||||
}
|
||||
@ -110,7 +118,7 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatPeerListCoordinator {
|
||||
extension ChatPeerListCoordinator {
|
||||
@MainActor
|
||||
func handlePeerListUpdate(_ peers: [PeerID]) {
|
||||
context.isConnected = !peers.isEmpty
|
||||
@ -142,15 +150,27 @@ private extension ChatPeerListCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
invalidateNetworkEmptyTimer()
|
||||
context.recordMeshSightings(peerIDs: meshPeers)
|
||||
|
||||
let newPeers = meshPeerSet.subtracting(recentlySeenPeers)
|
||||
// Record every sighted peer even when no notification fires. A peer
|
||||
// Only peers that aren't blocked / proximity-muted count toward the
|
||||
// empty→populated notification and the reset-path emptiness check.
|
||||
// A muted-only mesh stays "empty" so a stranger joining later still
|
||||
// alerts, and so meshWasEmpty can reset after the first alert.
|
||||
let countablePeers = meshPeers.filter { !context.suppressesNearbyNotification(for: $0) }
|
||||
if countablePeers.isEmpty {
|
||||
scheduleNetworkEmptyTimer()
|
||||
return
|
||||
}
|
||||
|
||||
invalidateNetworkEmptyTimer()
|
||||
|
||||
let countableSet = Set(countablePeers)
|
||||
let newPeers = countableSet.subtracting(recentlySeenPeers)
|
||||
// Record every countable peer even when no notification fires. A peer
|
||||
// first seen during the cooldown (or while already meshed) must not
|
||||
// still count as "new" at some later peer-list event — that re-fired
|
||||
// the notification while devices sat idle and connected.
|
||||
recentlySeenPeers.formUnion(meshPeerSet)
|
||||
recentlySeenPeers.formUnion(countableSet)
|
||||
|
||||
let cameFromEmpty = meshWasEmpty
|
||||
meshWasEmpty = false
|
||||
@ -159,9 +179,9 @@ private extension ChatPeerListCoordinator {
|
||||
|
||||
if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds {
|
||||
lastNetworkNotificationTime = Date()
|
||||
context.notifyNetworkAvailable(peerCount: meshPeers.count)
|
||||
context.notifyNetworkAvailable(peerCount: countablePeers.count)
|
||||
SecureLogger.info(
|
||||
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||
"👥 Sent bitchatters nearby notification for \(countablePeers.count) mesh peers (new: \(newPeers.count))",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
@ -1303,6 +1303,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
peerIdentityCoordinator.isPeerBlocked(peerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func suppressesNearbyNotification(for peerID: PeerID) -> Bool {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||
return identityManager.suppressesNearbyNotification(fingerprint: fingerprint)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isNearbyNotificationMuted(for peerID: PeerID) -> Bool {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||
return identityManager.isNearbyNotificationMuted(fingerprint: fingerprint)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setNearbyNotificationMuted(for peerID: PeerID, muted: Bool) {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else {
|
||||
// Pre-handshake peers have no Noise fingerprint yet — the mute
|
||||
// key is fingerprint-stable, so there is nothing to persist.
|
||||
SecureLogger.debug(
|
||||
"🔇 Nearby-notification mute no-op for \(peerID.id.prefix(8))… (no fingerprint yet)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
identityManager.setNearbyNotificationMuted(fingerprint, muted: muted)
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
// Helper method to update selectedPrivateChatPeer if fingerprint matches
|
||||
@MainActor
|
||||
func updatePrivateChatPeerIfNeeded() {
|
||||
|
||||
@ -388,6 +388,9 @@ private struct ContentPeopleListView: View {
|
||||
} else {
|
||||
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
|
||||
}
|
||||
},
|
||||
onToggleNearbyNotificationMute: { peer in
|
||||
peerListModel.toggleNearbyNotificationMute(peerID: peer.peerID)
|
||||
}
|
||||
)
|
||||
// People in this area but beyond radio range, and
|
||||
|
||||
@ -10,6 +10,8 @@ struct MeshPeerList: View {
|
||||
/// Optional so existing call sites (and previews/tests) keep compiling;
|
||||
/// when absent the block/unblock context-menu entry is hidden.
|
||||
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
|
||||
/// Mute/unmute this peer for the "bitchatters nearby" notification.
|
||||
var onToggleNearbyNotificationMute: ((MeshPeerRow) -> Void)? = nil
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
@ -34,6 +36,9 @@ 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 muteNearby = String(localized: "mesh_peers.action.mute_nearby", defaultValue: "Mute nearby alerts", comment: "Context menu action to stop nearby notifications for this peer")
|
||||
static let unmuteNearby = String(localized: "mesh_peers.action.unmute_nearby", defaultValue: "Unmute nearby alerts", comment: "Context menu action to resume nearby notifications for this peer")
|
||||
static let nearbyMuted = String(localized: "mesh_peers.state.nearby_muted", defaultValue: "nearby alerts muted", comment: "State label for a peer muted for proximity notifications")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -114,6 +119,11 @@ struct MeshPeerList: View {
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.red)
|
||||
.help(Strings.blockedTooltip)
|
||||
} else if peer.isNearbyNotificationMuted {
|
||||
Image(systemName: "bell.slash")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(palette.secondary)
|
||||
.help(Strings.nearbyMuted)
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
@ -195,6 +205,11 @@ struct MeshPeerList: View {
|
||||
Button(Strings.showFingerprint) {
|
||||
onShowFingerprint(peer.peerID)
|
||||
}
|
||||
if let onToggleNearbyNotificationMute {
|
||||
Button(peer.isNearbyNotificationMuted ? Strings.unmuteNearby : Strings.muteNearby) {
|
||||
onToggleNearbyNotificationMute(peer)
|
||||
}
|
||||
}
|
||||
if let onToggleBlock {
|
||||
if peer.isBlocked {
|
||||
Button(Strings.unblock) {
|
||||
@ -220,6 +235,11 @@ struct MeshPeerList: View {
|
||||
Button(Strings.showFingerprint) {
|
||||
onShowFingerprint(peer.peerID)
|
||||
}
|
||||
if let onToggleNearbyNotificationMute {
|
||||
Button(peer.isNearbyNotificationMuted ? Strings.unmuteNearby : Strings.muteNearby) {
|
||||
onToggleNearbyNotificationMute(peer)
|
||||
}
|
||||
}
|
||||
if let onToggleBlock {
|
||||
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
|
||||
onToggleBlock(peer)
|
||||
@ -261,6 +281,7 @@ struct MeshPeerList: View {
|
||||
if peer.isFavorite { parts.append(Strings.favorite) }
|
||||
if peer.hasUnread { parts.append(Strings.unread) }
|
||||
if peer.isBlocked { parts.append(Strings.blocked) }
|
||||
if peer.isNearbyNotificationMuted { parts.append(Strings.nearbyMuted) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,11 +64,16 @@ private final class MockChatPeerListContext: ChatPeerListContext {
|
||||
|
||||
// Notifications
|
||||
private(set) var networkAvailableNotifications: [Int] = []
|
||||
var suppressedNearbyPeerIDs: Set<PeerID> = []
|
||||
|
||||
func notifyNetworkAvailable(peerCount: Int) {
|
||||
networkAvailableNotifications.append(peerCount)
|
||||
}
|
||||
|
||||
func suppressesNearbyNotification(for peerID: PeerID) -> Bool {
|
||||
suppressedNearbyPeerIDs.contains(peerID)
|
||||
}
|
||||
|
||||
// Sightings
|
||||
private(set) var recordedSightings: [[PeerID]] = []
|
||||
|
||||
@ -279,4 +284,55 @@ struct ChatPeerListCoordinatorContextTests {
|
||||
await drainMainActorTasks()
|
||||
#expect(context.networkAvailableNotifications.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_mutedPeersDoNotTriggerNearbyNotification() async {
|
||||
let context = MockChatPeerListContext()
|
||||
let coordinator = ChatPeerListCoordinator(context: context)
|
||||
let mutedPeer = PeerID(str: "0011223344556677")
|
||||
let stranger = PeerID(str: "8899aabbccddeeff")
|
||||
context.connectedMeshPeers = [mutedPeer, stranger]
|
||||
context.suppressedNearbyPeerIDs = [mutedPeer]
|
||||
|
||||
// Only the muted (own) device is nearby: stay quiet.
|
||||
coordinator.didUpdatePeerList([mutedPeer])
|
||||
await drainMainActorTasks()
|
||||
#expect(context.networkAvailableNotifications.isEmpty)
|
||||
|
||||
// A stranger joining after muted-only mesh still alerts.
|
||||
coordinator.didUpdatePeerList([mutedPeer, stranger])
|
||||
await drainMainActorTasks()
|
||||
#expect(context.networkAvailableNotifications == [1])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_mutedOnlyMeshResetsEmptyGateForNextStranger() async {
|
||||
let context = MockChatPeerListContext()
|
||||
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
|
||||
let mutedPeer = PeerID(str: "0011223344556677")
|
||||
let stranger = PeerID(str: "8899aabbccddeeff")
|
||||
context.connectedMeshPeers = [mutedPeer, stranger]
|
||||
context.suppressedNearbyPeerIDs = [mutedPeer]
|
||||
|
||||
// First stranger alerts and marks the mesh populated.
|
||||
coordinator.didUpdatePeerList([mutedPeer, stranger])
|
||||
await drainMainActorTasks()
|
||||
#expect(context.networkAvailableNotifications == [1])
|
||||
|
||||
// Stranger leaves; only the muted peer remains. Reset-path count
|
||||
// must treat that as empty (activeMeshPeerCount excludes muted).
|
||||
context.connectedMeshPeers = [mutedPeer]
|
||||
context.activeMeshPeerCountValue = 0
|
||||
coordinator.didUpdatePeerList([mutedPeer])
|
||||
await drainMainActorTasks()
|
||||
coordinator.handleNetworkEmptyTimerFired()
|
||||
|
||||
// A later stranger must alert again — the muted peer must not pin
|
||||
// meshWasEmpty to false forever.
|
||||
context.connectedMeshPeers = [mutedPeer, stranger]
|
||||
context.activeMeshPeerCountValue = 1
|
||||
coordinator.didUpdatePeerList([mutedPeer, stranger])
|
||||
await drainMainActorTasks()
|
||||
#expect(context.networkAvailableNotifications == [1, 1])
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,6 +70,24 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
blockedFingerprints.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
private var nearbyNotificationMutedFingerprints: Set<String> = []
|
||||
|
||||
func isNearbyNotificationMuted(fingerprint: String) -> Bool {
|
||||
nearbyNotificationMutedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func suppressesNearbyNotification(fingerprint: String) -> Bool {
|
||||
isBlocked(fingerprint: fingerprint) || isNearbyNotificationMuted(fingerprint: fingerprint)
|
||||
}
|
||||
|
||||
func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) {
|
||||
if muted {
|
||||
nearbyNotificationMutedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
nearbyNotificationMutedFingerprints.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
blockedNostrPubkeys.contains(pubkeyHexLowercased)
|
||||
|
||||
@ -195,6 +195,28 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32)))
|
||||
}
|
||||
|
||||
func test_nearbyNotificationMute_persistsAndSuppressesWithoutBlocking() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
|
||||
XCTAssertFalse(manager.isNearbyNotificationMuted(fingerprint: fingerprint))
|
||||
XCTAssertFalse(manager.suppressesNearbyNotification(fingerprint: fingerprint))
|
||||
|
||||
manager.setNearbyNotificationMuted(fingerprint, muted: true)
|
||||
let muted = await waitUntil { manager.isNearbyNotificationMuted(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(muted)
|
||||
XCTAssertTrue(manager.suppressesNearbyNotification(fingerprint: fingerprint))
|
||||
XCTAssertFalse(manager.isBlocked(fingerprint: fingerprint))
|
||||
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertTrue(reloaded.isNearbyNotificationMuted(fingerprint: fingerprint))
|
||||
|
||||
manager.setNearbyNotificationMuted(fingerprint, muted: false)
|
||||
let unmuted = await waitUntil { !manager.isNearbyNotificationMuted(fingerprint: fingerprint) }
|
||||
XCTAssertTrue(unmuted)
|
||||
}
|
||||
|
||||
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "cd", count: 32)
|
||||
|
||||
@ -241,6 +241,24 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
private var nearbyMuted: Set<String> = []
|
||||
|
||||
func isNearbyNotificationMuted(fingerprint: String) -> Bool {
|
||||
nearbyMuted.contains(fingerprint)
|
||||
}
|
||||
|
||||
func suppressesNearbyNotification(fingerprint: String) -> Bool {
|
||||
isBlocked(fingerprint: fingerprint) || nearbyMuted.contains(fingerprint)
|
||||
}
|
||||
|
||||
func setNearbyNotificationMuted(_ fingerprint: String, muted: Bool) {
|
||||
if muted {
|
||||
nearbyMuted.insert(fingerprint)
|
||||
} else {
|
||||
nearbyMuted.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
blockedNostr.contains(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user