mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 22:25:19 +00:00
Deliver live DM transport events synchronously (#1465)
Removes the nested-Task deferral when typed transport events reach ChatViewModel: didReceiveTransportEvent now dispatches to @MainActor *Synchronously coordinator methods inline within the single notifyUI main-actor hop, fixing a real ordering race where a delivery-status update emitted right after a message could apply before the message existed in the ConversationStore and be dropped. Adds SynchronousMessageTransportEventDelegate so BLEService.deliverTransportEvent returns a documented accepted/unconfirmed verdict for .messageReceived (consumed by the private-media receipt layer above). Intra-main-actor only — no cross-queue sync added; the bleQueue-never-blocks-on-main invariant is preserved.
This commit is contained in:
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
|
||||
/// no `ChatViewModel`.
|
||||
struct ChatPeerListCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousPeerListUpdate_appliesBeforeReturning() {
|
||||
let context = MockChatPeerListContext()
|
||||
let coordinator = ChatPeerListCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
|
||||
coordinator.didUpdatePeerListSynchronously([peerID])
|
||||
|
||||
#expect(context.isConnected)
|
||||
#expect(context.registeredEphemeralSessions == [peerID])
|
||||
#expect(context.updateEncryptionStatusForPeersCount == 1)
|
||||
#expect(context.cleanupOldReadReceiptsCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
||||
let context = MockChatPeerListContext()
|
||||
|
||||
@@ -242,6 +242,30 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let blocked = makeMessage(
|
||||
id: "blocked-private-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.blockedMessageIDs = [blocked.id]
|
||||
|
||||
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
|
||||
let accepted = makeMessage(
|
||||
id: "accepted-private-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
|
||||
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
|
||||
let context = MockChatTransportEventContext()
|
||||
@@ -295,6 +319,32 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousConnectAndDisconnect_applyBeforeReturning() {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "2233445566778899")
|
||||
let incoming = makeMessage(
|
||||
id: "incoming-receipt",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.privateChats[peerID] = [incoming]
|
||||
|
||||
coordinator.didConnectToPeerSynchronously(peerID)
|
||||
|
||||
#expect(context.isConnected)
|
||||
#expect(context.registeredEphemeralSessions == [peerID])
|
||||
#expect(context.flushedOutboxPeerIDs == [peerID])
|
||||
#expect(context.courierRetryPeerIDs == [peerID])
|
||||
|
||||
coordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||
|
||||
#expect(context.removedEphemeralSessions == [peerID])
|
||||
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
||||
let context = MockChatTransportEventContext()
|
||||
|
||||
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func typedPeerLifecycleEvents_applyBeforeReturning() {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let incoming = BitchatMessage(
|
||||
id: "typed-peer-incoming",
|
||||
sender: "Alice",
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
viewModel.seedPrivateChat([incoming], for: peerID)
|
||||
viewModel.sentReadReceipts.insert(incoming.id)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
|
||||
|
||||
#expect(viewModel.isConnected)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
|
||||
|
||||
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let stalePeer = PeerID(str: "00000000000000a2")
|
||||
let deliveryPeer = PeerID(str: "0102030405060708")
|
||||
let messageID = "typed-delivery-status"
|
||||
let delivered = DeliveryStatus.delivered(
|
||||
to: "Alice",
|
||||
at: Date(timeIntervalSince1970: 1_234)
|
||||
)
|
||||
let outgoing = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "On the way",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Alice",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.markPrivateChatUnread(stalePeer)
|
||||
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
|
||||
|
||||
viewModel.didReceiveTransportEvent(
|
||||
.messageDeliveryStatusUpdated(
|
||||
messageID: messageID,
|
||||
status: delivered
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
|
||||
== delivered
|
||||
)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
|
||||
#expect(viewModel.bluetoothState == .poweredOff)
|
||||
#expect(viewModel.showBluetoothAlert)
|
||||
|
||||
// Snapshot events belong to TransportPeerEventsDelegate and are
|
||||
// intentionally ignored at this typed sink.
|
||||
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
|
||||
#expect(viewModel.bluetoothState == .poweredOff)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didConnectToPeer_notifiesDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
Reference in New Issue
Block a user