mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:25:20 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user