Fix live DM handoff across transport aliases

This commit is contained in:
jack
2026-07-25 23:49:42 +02:00
committed by jack
parent c056a46299
commit f6abd10fb5
6 changed files with 558 additions and 22 deletions
@@ -177,6 +177,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
var geohashPrivateMessageAccepted = true
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
private(set) var deliveredMessageIDs: [String] = []
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, messageID))
@@ -188,6 +191,22 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
return routeReadReceiptResult
}
@discardableResult
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool {
deliveryAckAttempts.append((messageID, peerIDs))
var cleared = false
for peerID in Set(peerIDs) {
guard var queued = queuedMessageIDsByPeerID[peerID],
queued.remove(messageID) != nil else { continue }
queuedMessageIDsByPeerID[peerID] = queued.isEmpty ? nil : queued
cleared = true
}
if cleared {
deliveredMessageIDs.append(messageID)
}
return cleared
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
meshReadReceipts.append((receipt.originalMessageID, peerID))
}
@@ -382,6 +401,66 @@ struct ChatPrivateConversationCoordinatorContextTests {
convKey: convKey
)
#expect(context.notifyUIChangedCount == 2)
#expect(context.deliveryAckAttempts.isEmpty)
#expect(context.deliveredMessageIDs.isEmpty)
}
@Test @MainActor
func accountDMAcks_findShortIDMessageAndHandConversationToStable() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xD5, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
let shortPeerID = stablePeerID.toShort()
let senderPubkey = "feedface00112233"
context.displayNamesByPubkey[senderPubkey] = "alice"
context.selectedPrivateChatPeer = shortPeerID
context.privateChats[shortPeerID] = [
makeIncomingMessage(id: "mine-short-1", sender: "me"),
makeIncomingMessage(id: "mine-short-2", sender: "me")
]
context.queuedMessageIDsByPeerID[shortPeerID] = ["mine-short-1", "mine-short-2"]
coordinator.handleDelivered(
NoisePayload(type: .delivered, data: Data("mine-short-1".utf8)),
senderPubkey: senderPubkey,
convKey: stablePeerID
)
coordinator.handleReadReceipt(
NoisePayload(type: .readReceipt, data: Data("mine-short-2".utf8)),
senderPubkey: senderPubkey,
convKey: stablePeerID
)
#expect(context.privateChats[shortPeerID] == nil)
#expect(isDelivered(context.privateChats[stablePeerID]?.first?.deliveryStatus, to: "alice"))
#expect(isRead(context.privateChats[stablePeerID]?.last?.deliveryStatus, by: "alice"))
#expect(context.selectedPrivateChatPeer == stablePeerID)
#expect(context.deliveredMessageIDs == ["mine-short-1", "mine-short-2"])
#expect(context.notifyUIChangedCount == 2)
}
@Test @MainActor
func accountDMAck_doesNotTouchAnUnrelatedConversationWithTheSameMessageID() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let stablePeerID = PeerID(hexData: Data(repeating: 0xC1, count: 32))
let unrelatedPeerID = PeerID(str: "1111222233334444")
context.privateChats[unrelatedPeerID] = [
makeIncomingMessage(id: "collision", sender: "me")
]
context.queuedMessageIDsByPeerID[unrelatedPeerID] = ["collision"]
coordinator.handleDelivered(
NoisePayload(type: .delivered, data: Data("collision".utf8)),
senderPubkey: "feedface00112233",
convKey: stablePeerID
)
#expect(isDelivered(context.privateChats[unrelatedPeerID]?.first?.deliveryStatus, to: "me"))
#expect(context.deliveredMessageIDs.isEmpty)
#expect(context.queuedMessageIDsByPeerID[unrelatedPeerID] == ["collision"])
#expect(context.notifyUIChangedCount == 0)
}
@Test @MainActor
@@ -443,6 +522,180 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
}
@Test @MainActor
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xA7, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
let shortPeerID = stablePeerID.toShort()
let senderPubkey = "feedface00112233"
let now = Date()
context.displayNamesByPubkey[senderPubkey] = "bob"
context.selectedPrivateChatPeer = shortPeerID
context.privateChats[shortPeerID] = [
makeIncomingMessage(
id: "short-history",
sender: "me",
timestamp: now.addingTimeInterval(-30),
senderPeerID: context.myPeerID
),
makeIncomingMessage(
id: "short-inbound",
sender: "bob",
timestamp: now.addingTimeInterval(-25),
senderPeerID: shortPeerID
)
]
context.privateChats[stablePeerID] = [
makeIncomingMessage(
id: "stable-history",
sender: "bob",
timestamp: now.addingTimeInterval(-20),
senderPeerID: stablePeerID
)
]
let payloadData = PrivateMessagePacket(messageID: "account-live-1", content: "live reply").encode()!
coordinator.handlePrivateMessage(
NoisePayload(type: .privateMessage, data: payloadData),
senderPubkey: senderPubkey,
convKey: stablePeerID,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: now
)
#expect(context.privateChats[shortPeerID] == nil)
#expect(context.privateChats[stablePeerID]?.map(\.id) == [
"short-history",
"short-inbound",
"stable-history",
"account-live-1"
])
#expect(context.privateChats[stablePeerID]?[1].senderPeerID == stablePeerID)
#expect(context.privateChats[stablePeerID]?.last?.senderPeerID == stablePeerID)
#expect(context.migratedChats.contains(where: { $0.from == shortPeerID && $0.to == stablePeerID }))
#expect(context.selectedPrivateChatPeer == stablePeerID)
#expect(context.geoReadReceipts.map(\.messageID) == ["account-live-1"])
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func accountDM_aliasMergePreservesTheCanonicalDestinationCopy() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xA8, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
let shortPeerID = stablePeerID.toShort()
let senderPubkey = "feedface00112233"
let olderSource = makeIncomingMessage(
id: "duplicate-history",
sender: "bob",
content: "older source copy",
senderPeerID: shortPeerID
)
olderSource.deliveryStatus = .delivered(to: "me", at: Date(timeIntervalSince1970: 10))
let newerDestination = makeIncomingMessage(
id: "duplicate-history",
sender: "bob",
content: "newer destination copy",
senderPeerID: shortPeerID
)
newerDestination.deliveryStatus = .read(by: "me", at: Date(timeIntervalSince1970: 20))
context.privateChats[shortPeerID] = [olderSource]
context.privateChats[stablePeerID] = [newerDestination]
context.displayNamesByPubkey[senderPubkey] = "bob"
let payloadData = PrivateMessagePacket(messageID: "after-merge", content: "new reply").encode()!
coordinator.handlePrivateMessage(
NoisePayload(type: .privateMessage, data: payloadData),
senderPubkey: senderPubkey,
convKey: stablePeerID,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date()
)
let merged = context.privateChats[stablePeerID]?.first
#expect(context.privateChats[shortPeerID] == nil)
#expect(merged?.content == "newer destination copy")
#expect(isRead(merged?.deliveryStatus, by: "me"))
#expect(merged?.senderPeerID == stablePeerID)
}
@Test @MainActor
func accountDM_keepsTheConnectedShortIDConversationCanonical() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xB8, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
let shortPeerID = stablePeerID.toShort()
let senderPubkey = "feedface00112233"
context.connectedPeers = [shortPeerID]
context.selectedPrivateChatPeer = stablePeerID
context.displayNamesByPubkey[senderPubkey] = "bob"
context.privateChats[stablePeerID] = [
makeIncomingMessage(id: "stable-history", senderPeerID: stablePeerID)
]
let payloadData = PrivateMessagePacket(messageID: "connected-live-1", content: "still nearby").encode()!
coordinator.handlePrivateMessage(
NoisePayload(type: .privateMessage, data: payloadData),
senderPubkey: senderPubkey,
convKey: stablePeerID,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date()
)
#expect(context.privateChats[stablePeerID] == nil)
#expect(context.privateChats[shortPeerID]?.map(\.id) == ["stable-history", "connected-live-1"])
#expect(context.privateChats[shortPeerID]?.first?.senderPeerID == shortPeerID)
#expect(context.privateChats[shortPeerID]?.last?.senderPeerID == shortPeerID)
#expect(context.selectedPrivateChatPeer == shortPeerID)
#expect(context.geoReadReceipts.map(\.messageID) == ["connected-live-1"])
}
@Test @MainActor
func accountDM_duplicateDowngradeAckStillClearsTheRetainedOutbox() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let stablePeerID = PeerID(hexData: Data(repeating: 0xE2, count: 32))
let message = makeIncomingMessage(id: "already-read", sender: "me")
message.deliveryStatus = .read(by: "bob", at: Date())
context.privateChats[stablePeerID] = [message]
context.queuedMessageIDsByPeerID[stablePeerID] = ["already-read"]
coordinator.handleDelivered(
NoisePayload(type: .delivered, data: Data("already-read".utf8)),
senderPubkey: "feedface00112233",
convKey: stablePeerID
)
#expect(isRead(context.privateChats[stablePeerID]?.first?.deliveryStatus, by: "bob"))
#expect(context.deliveredMessageIDs == ["already-read"])
#expect(context.notifyUIChangedCount == 0)
}
@Test @MainActor
func accountDMAck_clearsRetainedMessageAfterConversationWasRemoved() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let stablePeerID = PeerID(hexData: Data(repeating: 0xE3, count: 32))
let shortPeerID = stablePeerID.toShort()
context.queuedMessageIDsByPeerID[shortPeerID] = ["cleared-bubble"]
coordinator.handleDelivered(
NoisePayload(type: .delivered, data: Data("cleared-bubble".utf8)),
senderPubkey: "feedface00112233",
convKey: stablePeerID
)
#expect(context.privateChats.isEmpty)
#expect(context.queuedMessageIDsByPeerID[shortPeerID] == nil)
#expect(context.deliveredMessageIDs == ["cleared-bubble"])
#expect(context.notifyUIChangedCount == 0)
}
@Test @MainActor
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
let context = MockChatPrivateConversationContext()