mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 12:45:19 +00:00
Fix live DM handoff across transport aliases
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user