Convert shared coordinator state to owner-side intent operations

nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe,
isBatchingPublic, geo subscription lifecycle, and private-chat selection
hand-off now mutate through single intent operations on ChatViewModel,
with backing storage locked down via private(set) so the single-writer
property is compiler-enforced. Context protocols downgrade to read-only
access where reads remain. 8 new contract tests.

Known remaining writers outside the protocols: sentReadReceipts is
passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and
un-marked by ChatTransportEventCoordinator on disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 21:26:21 +02:00
co-authored by Claude Fable 5
parent 80bed1f395
commit 707b22878d
12 changed files with 358 additions and 64 deletions
@@ -14,8 +14,11 @@ import Foundation
protocol ChatDeliveryContext: AnyObject {
var messages: [BitchatMessage] { get set }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var sentReadReceipts: Set<String> { get set }
var isStartupPhase: Bool { get }
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
/// Returns the number of receipts removed. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
/// Confirms receipt so the message router stops retaining the message for resend.
@@ -57,10 +60,7 @@ final class ChatDeliveryCoordinator {
}
)
let oldCount = context.sentReadReceipts.count
context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs)
let removedCount = oldCount - context.sentReadReceipts.count
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
if removedCount > 0 {
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
}
+31 -23
View File
@@ -18,10 +18,17 @@ protocol ChatNostrContext: AnyObject {
// MARK: Channel & subscription state
var activeChannel: ChannelID { get set }
var currentGeohash: String? { get set }
var geoSubscriptionID: String? { get set }
var geoDmSubscriptionID: String? { get set }
var geoSubscriptionID: String? { get }
var geoDmSubscriptionID: String? { get }
func setGeoChatSubscriptionID(_ id: String?)
func setGeoDmSubscriptionID(_ id: String?)
/// Geohash sampling subscriptions: subscription ID -> geohash.
var geoSamplingSubs: [String: String] { get set }
var geoSamplingSubs: [String: String] { get }
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String)
func removeGeoSamplingSub(_ subID: String)
/// Clears all sampling subscriptions and returns the removed subscription IDs
/// so the caller can unsubscribe them from the relay manager.
func clearGeoSamplingSubs() -> [String]
/// Per-geohash notification cooldown: geohash -> last notify time.
var lastGeoNotificationAt: [String: Date] { get set }
var nostrRelayManager: NostrRelayManager? { get }
@@ -44,7 +51,9 @@ protocol ChatNostrContext: AnyObject {
// MARK: Inbound private (geohash DM) payloads
var selectedPrivateChatPeer: PeerID? { get }
var nostrKeyMapping: [PeerID: String] { get set }
var nostrKeyMapping: [PeerID: String] { get }
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
@@ -225,12 +234,12 @@ final class ChatNostrCoordinator {
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.geoDmSubscriptionID = nil
context.setGeoDmSubscriptionID(nil)
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
context.geoDmSubscriptionID = dmSub
context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
@@ -274,8 +283,8 @@ final class ChatNostrCoordinator {
context.setGeoNickname(nick, forPubkey: event.pubkey)
}
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
context.recordGeoParticipant(pubkeyHex: event.pubkey)
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
@@ -349,7 +358,7 @@ final class ChatNostrCoordinator {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
context.nostrKeyMapping[convKey] = senderPubkey
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch noisePayload.type {
case .privateMessage:
@@ -400,11 +409,11 @@ final class ChatNostrCoordinator {
if let sub = context.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
context.geoSubscriptionID = nil
context.setGeoChatSubscriptionID(nil)
}
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.geoDmSubscriptionID = nil
context.setGeoDmSubscriptionID(nil)
}
context.currentGeohash = nil
context.setActiveParticipantGeohash(nil)
@@ -429,7 +438,7 @@ final class ChatNostrCoordinator {
}
let subID = "geo-\(channel.geohash)"
context.geoSubscriptionID = subID
context.setGeoChatSubscriptionID(subID)
context.startGeoParticipantRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
@@ -504,8 +513,8 @@ final class ChatNostrCoordinator {
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
}
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
@@ -547,7 +556,7 @@ final class ChatNostrCoordinator {
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
let dmSub = "geo-dm-\(channel.geohash)"
context.geoDmSubscriptionID = dmSub
context.setGeoDmSubscriptionID(dmSub)
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
@@ -593,7 +602,7 @@ final class ChatNostrCoordinator {
}
let convKey = PeerID(nostr_: senderPubkey)
context.nostrKeyMapping[convKey] = senderPubkey
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
@@ -633,7 +642,7 @@ final class ChatNostrCoordinator {
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug(
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
category: .session
@@ -666,7 +675,7 @@ final class ChatNostrCoordinator {
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
context.geoSamplingSubs.removeValue(forKey: subID)
context.removeGeoSamplingSub(subID)
}
for gh in toAdd {
@@ -678,7 +687,7 @@ final class ChatNostrCoordinator {
func subscribe(_ gh: String) {
guard let context else { return }
let subID = "geo-sample-\(gh)"
context.geoSamplingSubs[subID] = gh
context.addGeoSamplingSub(subID, forGeohash: gh)
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
@@ -771,10 +780,9 @@ final class ChatNostrCoordinator {
@MainActor
func endGeohashSampling() {
guard let context else { return }
for subID in context.geoSamplingSubs.keys {
for subID in context.clearGeoSamplingSubs() {
NostrRelayManager.shared.unsubscribe(id: subID)
}
context.geoSamplingSubs.removeAll()
clearGeoSamplingEventDedup()
}
@@ -885,7 +893,7 @@ final class ChatNostrCoordinator {
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
context.nostrKeyMapping[targetPeerID] = senderPubkey
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
@@ -1063,7 +1071,7 @@ final class ChatNostrCoordinator {
func startGeohashDM(withPubkeyHex hex: String) {
guard let context else { return }
let convKey = PeerID(nostr_: hex)
context.nostrKeyMapping[convKey] = hex
context.registerNostrKeyMapping(hex, for: convKey)
context.startPrivateChat(with: convKey)
}
@@ -301,7 +301,7 @@ final class ChatPeerIdentityCoordinator {
.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
viewModel.nostrKeyMapping[conversationKey] = person.id
viewModel.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
@@ -312,7 +312,7 @@ final class ChatPeerIdentityCoordinator {
.lowercased() ?? nickname.lowercased()
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let conversationKey = PeerID(nostr_: pubkey)
viewModel.nostrKeyMapping[conversationKey] = pubkey
viewModel.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
@@ -15,13 +15,23 @@ import Foundation
protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get set }
var sentReadReceipts: Set<String> { get set }
var sentGeoDeliveryAcks: Set<String> { get set }
var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get set }
var selectedPrivateChatPeer: PeerID? { get set }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool
/// Moves the open private chat to `newPeerID` when the current selection is
/// one of the peer IDs being migrated away.
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
@@ -62,8 +72,10 @@ protocol ChatPrivateConversationContext: AnyObject {
}
extension ChatViewModel: ChatPrivateConversationContext {
// `privateChats`, `sentReadReceipts`, and `notifyUIChanged()` are shared
// requirements with `ChatDeliveryContext`; the remaining state members are
// `privateChats` and `notifyUIChanged()` are shared requirements with
// `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`,
// `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their
// backing state in `ChatViewModel`. The remaining state members are
// satisfied by existing `ChatViewModel` properties and methods.
var myPeerID: PeerID { meshService.myPeerID }
@@ -425,15 +437,13 @@ final class ChatPrivateConversationCoordinator {
}
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !context.sentGeoDeliveryAcks.contains(messageId) else { return }
guard context.markGeoDeliveryAckSent(messageId) else { return }
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
context.sentGeoDeliveryAcks.insert(messageId)
}
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !context.sentReadReceipts.contains(messageId) else { return }
guard context.markReadReceiptSent(messageId) else { return }
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
context.sentReadReceipts.insert(messageId)
}
func handlePrivateMessage(
@@ -579,7 +589,7 @@ final class ChatPrivateConversationCoordinator {
readerNickname: context.nickname
)
context.sendMeshReadReceipt(receipt, to: peerID)
context.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
} else {
context.unreadPrivateMessages.insert(peerID)
NotificationService.shared.sendPrivateMessageNotification(
@@ -654,10 +664,10 @@ final class ChatPrivateConversationCoordinator {
)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
context.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
context.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
@@ -802,17 +812,13 @@ final class ChatPrivateConversationCoordinator {
}
if !oldPeerIDsToRemove.isEmpty {
let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 }
for oldID in oldPeerIDsToRemove {
context.privateChats.removeValue(forKey: oldID)
context.unreadPrivateMessages.remove(oldID)
context.clearStoredFingerprint(for: oldID)
}
if needsSelectedUpdate {
context.selectedPrivateChatPeer = peerID
}
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
}
if !migratedMessages.isEmpty {
@@ -25,7 +25,10 @@ protocol ChatPublicConversationContext: AnyObject {
var currentGeohash: String? { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var isBatchingPublic: Bool { get set }
/// Publishes the public-timeline batching state (UI animation suppression).
/// (Single mutation path for the owner's `isBatchingPublic`; this
/// coordinator never reads it.)
func setPublicBatching(_ isBatching: Bool)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
func trimMessagesIfNeeded()
@@ -56,7 +59,9 @@ protocol ChatPublicConversationContext: AnyObject {
// MARK: Geohash participants & presence
var geoNicknames: [String: String] { get }
var isTeleported: Bool { get }
var nostrKeyMapping: [PeerID: String] { get set }
var nostrKeyMapping: [PeerID: String] { get }
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String)
func visibleGeoPeople() -> [GeoPerson]
func geoParticipantCount(for geohash: String) -> Int
func removeGeoParticipant(pubkeyHex: String)
@@ -88,7 +93,7 @@ protocol ChatPublicConversationContext: AnyObject {
extension ChatViewModel: ChatPublicConversationContext {
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
// `myPeerID`, `isTeleported`, `isBatchingPublic`, `notifyUIChanged()`,
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
// `deriveNostrIdentity(forGeohash:)`, and
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
@@ -247,9 +252,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.unreadPrivateMessages = unread
}
for (key, value) in context.nostrKeyMapping where value.lowercased() == hex {
context.nostrKeyMapping.removeValue(forKey: key)
}
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
addSystemMessage(
String(
@@ -616,7 +619,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
context.isBatchingPublic = isBatching
context.setPublicBatching(isBatching)
}
}
+99 -6
View File
@@ -292,8 +292,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
objectWillChange.send()
}
}
var geoSubscriptionID: String? = nil
var geoDmSubscriptionID: String? = nil
// Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below.
private(set) var geoSubscriptionID: String? = nil
private(set) var geoDmSubscriptionID: String? = nil
var currentGeohash: String? {
get { locationPresenceStore.currentGeohash }
set { locationPresenceStore.setCurrentGeohash(newValue) }
@@ -366,7 +367,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
} // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
// Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below.
private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking
@@ -384,7 +386,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Public message batching (UI perf)
let publicMessagePipeline: PublicMessagePipeline
@Published var isBatchingPublic: Bool = false
// Single-writer: mutate only via `setPublicBatching(_:)` below.
@Published private(set) var isBatchingPublic: Bool = false
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
@@ -403,7 +406,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
var sentGeoDeliveryAcks: Set<String> = []
// Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below.
private(set) var sentGeoDeliveryAcks: Set<String> = []
// Track app startup phase to prevent marking old messages as unread
var isStartupPhase = true
@@ -411,7 +415,96 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var torInitialReadyAnnounced: Bool = false
// Track Nostr pubkey mappings for unknown senders
var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
// Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below.
private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
// MARK: - Single-Writer Intent Operations
// Owner-side mutation paths for state the coordinator contexts may read
// but not write directly. Each op is the sole way to mutate its backing
// state, so check-then-mutate races between coordinators cannot occur.
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
@MainActor
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
nostrKeyMapping[peerID] = pubkey
}
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
@MainActor
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
nostrKeyMapping.removeValue(forKey: key)
}
}
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
sentGeoDeliveryAcks.insert(messageID).inserted
}
/// Drops every recorded read receipt whose message ID is no longer valid.
/// Returns the number of receipts removed.
@MainActor
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
return oldCount - sentReadReceipts.count
}
/// Publishes the public-timeline batching state (UI animation suppression).
@MainActor
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
}
@MainActor
func setGeoChatSubscriptionID(_ id: String?) {
geoSubscriptionID = id
}
@MainActor
func setGeoDmSubscriptionID(_ id: String?) {
geoDmSubscriptionID = id
}
@MainActor
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) {
geoSamplingSubs[subID] = geohash
}
@MainActor
func removeGeoSamplingSub(_ subID: String) {
geoSamplingSubs.removeValue(forKey: subID)
}
/// Clears all sampling subscriptions and returns the removed subscription IDs
/// so the caller can unsubscribe them from the relay manager.
@MainActor
func clearGeoSamplingSubs() -> [String] {
let subIDs = Array(geoSamplingSubs.keys)
geoSamplingSubs.removeAll()
return subIDs
}
/// Moves the open private chat to `newPeerID` when the current selection is
/// one of the peer IDs being migrated away (side-effectful: re-targets the
/// private chat session and resyncs the conversation stores).
@MainActor
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
selectedPrivateChatPeer = newPeerID
}
// MARK: - Initialization
@@ -28,6 +28,16 @@ private final class MockChatNostrContext: ChatNostrContext {
var lastGeoNotificationAt: [String: Date] = [:]
var nostrRelayManager: NostrRelayManager? { nil }
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
func clearGeoSamplingSubs() -> [String] {
defer { geoSamplingSubs.removeAll() }
return Array(geoSamplingSubs.keys)
}
// Public timeline & pipeline
var messages: [BitchatMessage] = []
private(set) var pipelineResetCount = 0
@@ -71,6 +81,7 @@ private final class MockChatNostrContext: ChatNostrContext {
// Inbound private (geohash DM) payloads
var selectedPrivateChatPeer: PeerID?
var nostrKeyMapping: [PeerID: String] = [:]
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
@@ -29,6 +29,21 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var notifyUIChangedCount = 0
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
sentGeoDeliveryAcks.insert(messageID).inserted
}
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
selectedPrivateChatPeer = newPeerID
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
@@ -31,10 +31,14 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
var currentGeohash: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var isBatchingPublic = false
private(set) var isBatchingPublic = false
private(set) var notifyUIChangedCount = 0
private(set) var trimMessagesCount = 0
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
@@ -147,6 +151,12 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
var geoParticipantCounts: [String: Int] = [:]
private(set) var removedGeoParticipants: [String] = []
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
nostrKeyMapping.removeValue(forKey: key)
}
}
func visibleGeoPeople() -> [GeoPerson] {
geoPeople
}
@@ -388,6 +388,12 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
private(set) var notifyUIChangedCount = 0
private(set) var markedDeliveredMessageIDs: [String] = []
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
return oldCount - sentReadReceipts.count
}
func notifyUIChanged() {
notifyUIChangedCount += 1
}
+128 -3
View File
@@ -225,7 +225,7 @@ struct ChatViewModelPrivateChatExtensionTests {
// "Check geohash (Nostr) blocks using mapping to full pubkey"
let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey
viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID)
viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true)
// Force isGeoChat/isGeoDM check to be true by setting prefix?
@@ -235,7 +235,7 @@ struct ChatViewModelPrivateChatExtensionTests {
// We need a peerID that looks like geo.
let geoPeerID = PeerID(nostr_: hexPubkey)
viewModel.nostrKeyMapping[geoPeerID] = hexPubkey
viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID)
let geoMessage = BitchatMessage(
id: "msg-geo-blocked",
@@ -789,7 +789,7 @@ struct ChatViewModelGeoDMTests {
let convKey = PeerID(nostr_: recipientHex)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.nostrKeyMapping[convKey] = recipientHex
viewModel.registerNostrKeyMapping(recipientHex, for: convKey)
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
viewModel.sendGeohashDM("hello", to: convKey)
@@ -829,6 +829,131 @@ struct ChatViewModelGeoDMTests {
}
}
// MARK: - Single-Writer Intent Operation Tests
/// Contracts for the owner-side intent ops that are the sole mutation paths
/// for `ChatViewModel`'s shared coordinator state (`nostrKeyMapping`,
/// `sentReadReceipts`, `sentGeoDeliveryAcks`, `isBatchingPublic`, the geo
/// subscription IDs, and the selected private chat hand-off).
struct ChatViewModelIntentOperationTests {
@Test @MainActor
func markGeoDeliveryAckSent_returnsFalseOnSecondCall() async {
let (viewModel, _) = makeTestableViewModel()
#expect(viewModel.markGeoDeliveryAckSent("geo-ack-1"))
#expect(!viewModel.markGeoDeliveryAckSent("geo-ack-1"))
#expect(viewModel.markGeoDeliveryAckSent("geo-ack-2"))
#expect(viewModel.sentGeoDeliveryAcks == ["geo-ack-1", "geo-ack-2"])
}
@Test @MainActor
func markReadReceiptSent_returnsFalseOnSecondCall() async {
let (viewModel, _) = makeTestableViewModel()
#expect(viewModel.markReadReceiptSent("read-1"))
#expect(!viewModel.markReadReceiptSent("read-1"))
#expect(viewModel.sentReadReceipts.contains("read-1"))
}
@Test @MainActor
func pruneSentReadReceipts_dropsStaleIDsAndReturnsRemovedCount() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.sentReadReceipts = ["keep-1", "keep-2", "drop-1", "drop-2"]
let removed = viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2", "unrelated"])
#expect(removed == 2)
#expect(viewModel.sentReadReceipts == ["keep-1", "keep-2"])
// Nothing stale left: a second prune removes nothing.
#expect(viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2"]) == 0)
}
@Test @MainActor
func registerNostrKeyMapping_isVisibleToNostrCoordinatorLookups() async {
let (viewModel, _) = makeTestableViewModel()
let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
let convKey = PeerID(nostr_: hex)
// Registered through the owner intent op (as e.g. the private
// conversation flow does) and resolved through the Nostr coordinator,
// which reads the same backing dictionary via `ChatNostrContext`.
viewModel.registerNostrKeyMapping(hex, for: convKey)
#expect(viewModel.nostrKeyMapping[convKey] == hex)
#expect(viewModel.nostrCoordinator.fullNostrHex(forSenderPeerID: convKey) == hex)
}
@Test @MainActor
func removeNostrKeyMappings_dropsEveryMappingForThePubkeyCaseInsensitively() async {
let (viewModel, _) = makeTestableViewModel()
let hex = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
let otherHex = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
viewModel.registerNostrKeyMapping(hex.uppercased(), for: PeerID(nostr: hex))
viewModel.registerNostrKeyMapping(hex, for: PeerID(nostr_: hex))
viewModel.registerNostrKeyMapping(otherHex, for: PeerID(nostr_: otherHex))
viewModel.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
#expect(viewModel.nostrKeyMapping[PeerID(nostr: hex)] == nil)
#expect(viewModel.nostrKeyMapping[PeerID(nostr_: hex)] == nil)
#expect(viewModel.nostrKeyMapping[PeerID(nostr_: otherHex)] == otherHex)
}
@Test @MainActor
func setPublicBatching_publishesBatchingState() async {
let (viewModel, _) = makeTestableViewModel()
#expect(!viewModel.isBatchingPublic)
viewModel.setPublicBatching(true)
#expect(viewModel.isBatchingPublic)
viewModel.setPublicBatching(false)
#expect(!viewModel.isBatchingPublic)
}
@Test @MainActor
func geoSubscriptionIntentOps_setClearAndDrainSubscriptions() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.setGeoChatSubscriptionID("geo-u4pruy")
viewModel.setGeoDmSubscriptionID("geo-dm-u4pruy")
#expect(viewModel.geoSubscriptionID == "geo-u4pruy")
#expect(viewModel.geoDmSubscriptionID == "geo-dm-u4pruy")
viewModel.setGeoChatSubscriptionID(nil)
viewModel.setGeoDmSubscriptionID(nil)
#expect(viewModel.geoSubscriptionID == nil)
#expect(viewModel.geoDmSubscriptionID == nil)
viewModel.addGeoSamplingSub("geo-sample-aaaa", forGeohash: "aaaa")
viewModel.addGeoSamplingSub("geo-sample-bbbb", forGeohash: "bbbb")
viewModel.removeGeoSamplingSub("geo-sample-aaaa")
#expect(viewModel.geoSamplingSubs == ["geo-sample-bbbb": "bbbb"])
let cleared = viewModel.clearGeoSamplingSubs()
#expect(cleared == ["geo-sample-bbbb"])
#expect(viewModel.geoSamplingSubs.isEmpty)
}
@Test @MainActor
func handOffSelectedPrivateChat_movesSelectionOnlyWhenSelectedPeerIsMigrated() async {
let (viewModel, _) = makeTestableViewModel()
let oldPeer = PeerID(str: "aaaaaaaaaaaaaaaa")
let unrelatedPeer = PeerID(str: "cccccccccccccccc")
let newPeer = PeerID(str: "bbbbbbbbbbbbbbbb")
// Selection not among the migrated peers: untouched.
viewModel.selectedPrivateChatPeer = unrelatedPeer
viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer)
#expect(viewModel.selectedPrivateChatPeer == unrelatedPeer)
// Selection being migrated away: handed off to the new peer.
viewModel.selectedPrivateChatPeer = oldPeer
viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer)
#expect(viewModel.selectedPrivateChatPeer == newPeer)
}
}
@Suite(.serialized)
struct ChatViewModelMediaTransferTests {
@@ -384,6 +384,16 @@ private final class PerfNostrContext: ChatNostrContext {
var lastGeoNotificationAt: [String: Date] = [:]
var nostrRelayManager: NostrRelayManager? { nil }
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
func clearGeoSamplingSubs() -> [String] {
defer { geoSamplingSubs.removeAll() }
return Array(geoSamplingSubs.keys)
}
var messages: [BitchatMessage] = []
func resetPublicMessagePipeline() {}
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
@@ -403,6 +413,7 @@ private final class PerfNostrContext: ChatNostrContext {
var selectedPrivateChatPeer: PeerID?
var nostrKeyMapping: [PeerID: String] = [:]
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
@@ -460,6 +471,12 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
func notifyUIChanged() {}
func markMessageDelivered(_ messageID: String) {}
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
return oldCount - sentReadReceipts.count
}
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
/// deterministic IDs and timestamps.
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {