mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 17:05:21 +00:00
Deliver live transport events synchronously
This commit is contained in:
@@ -4251,16 +4251,40 @@ extension BLEService {
|
|||||||
|
|
||||||
private func emitTransportEvent(_ event: TransportEvent) {
|
private func emitTransportEvent(_ event: TransportEvent) {
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(event)
|
_ = self?.deliverTransportEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
@discardableResult
|
||||||
|
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
||||||
|
if case .messageReceived(let message) = event {
|
||||||
|
if let synchronousDelegate =
|
||||||
|
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||||
|
return synchronousDelegate
|
||||||
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
}
|
||||||
|
if let eventDelegate {
|
||||||
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let synchronousDelegate =
|
||||||
|
delegate as? SynchronousMessageTransportEventDelegate {
|
||||||
|
return synchronousDelegate
|
||||||
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
|
return true
|
||||||
} else {
|
} else {
|
||||||
delegate?.receiveTransportEvent(event)
|
guard let delegate else { return false }
|
||||||
|
delegate.receiveTransportEvent(event)
|
||||||
|
if case .messageReceived = event {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject {
|
|||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional typed-event contract for sinks that can synchronously decide
|
||||||
|
/// whether an inbound message was accepted.
|
||||||
|
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
|
||||||
|
@MainActor
|
||||||
|
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
|||||||
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
|||||||
|
|
||||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.handlePeerListUpdate(peers)
|
self?.didUpdatePeerListSynchronously(peers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
|
||||||
|
handlePeerListUpdate(peers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatPeerListCoordinator {
|
private extension ChatPeerListCoordinator {
|
||||||
|
|||||||
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
runOnMain { context in
|
runOnMain { [self] context in
|
||||||
guard !context.isMessageBlocked(message) else { return }
|
handleReceivedMessage(message, in: context)
|
||||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
|
||||||
|
|
||||||
if message.isPrivate {
|
|
||||||
context.handlePrivateMessage(message)
|
|
||||||
} else {
|
|
||||||
context.handlePublicMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.checkForMentions(message)
|
|
||||||
context.sendHapticFeedback(for: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typed transport events already arrive on the main actor. Handle them
|
||||||
|
/// synchronously so observers see the ConversationStore mutation before
|
||||||
|
/// the transport completes delivery.
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||||
|
handleReceivedMessage(message, in: context)
|
||||||
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(
|
func didReceivePublicMessage(
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
|
|||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
messageID: String?
|
messageID: String?
|
||||||
) {
|
) {
|
||||||
runOnMain { context in
|
runOnMain { [self] context in
|
||||||
let normalized = content.trimmed
|
handlePublicMessage(
|
||||||
let mentions = context.parseMentions(from: normalized)
|
from: peerID,
|
||||||
let message = BitchatMessage(
|
nickname: nickname,
|
||||||
id: messageID,
|
content: content,
|
||||||
sender: nickname,
|
|
||||||
content: normalized,
|
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
messageID: messageID,
|
||||||
originalSender: nil,
|
in: context
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context.handlePublicMessage(message)
|
|
||||||
context.checkForMentions(message)
|
|
||||||
context.sendHapticFeedback(for: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceivePublicMessageSynchronously(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
messageID: String?
|
||||||
|
) {
|
||||||
|
handlePublicMessage(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(
|
func didReceiveNoisePayload(
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
type: NoisePayloadType,
|
type: NoisePayloadType,
|
||||||
@@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveNoisePayloadSynchronously(
|
||||||
|
from peerID: PeerID,
|
||||||
|
type: NoisePayloadType,
|
||||||
|
payload: Data,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
handleNoisePayload(
|
||||||
|
from: peerID,
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: PeerID) {
|
func didConnectToPeer(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
runOnMain { [weak self] _ in
|
||||||
|
self?.didConnectToPeerSynchronously(peerID)
|
||||||
runOnMain { context in
|
|
||||||
context.isConnected = true
|
|
||||||
context.registerEphemeralSession(peerID: peerID)
|
|
||||||
context.notifyUIChanged()
|
|
||||||
|
|
||||||
if let peer = context.unifiedPeer(for: peerID) {
|
|
||||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.flushRouterOutbox(for: peerID)
|
|
||||||
context.retryCourierDeposits(via: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didConnectToPeerSynchronously(_ peerID: PeerID) {
|
||||||
|
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||||
|
|
||||||
|
context.isConnected = true
|
||||||
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
|
||||||
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
|
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.flushRouterOutbox(for: peerID)
|
||||||
|
context.retryCourierDeposits(via: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||||
|
runOnMain { [weak self] _ in
|
||||||
|
self?.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
|
||||||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||||||
|
|
||||||
runOnMain { context in
|
context.removeEphemeralSession(peerID: peerID)
|
||||||
context.removeEphemeralSession(peerID: peerID)
|
|
||||||
|
|
||||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||||
if stablePeerID == nil,
|
if stablePeerID == nil,
|
||||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||||
let derivedPeerID = PeerID(hexData: key)
|
let derivedPeerID = PeerID(hexData: key)
|
||||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||||
stablePeerID = derivedPeerID
|
stablePeerID = derivedPeerID
|
||||||
}
|
|
||||||
|
|
||||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
|
||||||
currentPeerID == peerID,
|
|
||||||
let stablePeerID {
|
|
||||||
self.migrateSelectedConversationIfNeeded(
|
|
||||||
from: peerID,
|
|
||||||
to: stablePeerID,
|
|
||||||
in: context
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let receiptIDs = context.privateMessages(for: peerID)
|
|
||||||
.filter { $0.senderPeerID == peerID }
|
|
||||||
.map(\.id)
|
|
||||||
context.unmarkReadReceiptsSent(receiptIDs)
|
|
||||||
|
|
||||||
context.notifyUIChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||||
|
currentPeerID == peerID,
|
||||||
|
let stablePeerID {
|
||||||
|
migrateSelectedConversationIfNeeded(
|
||||||
|
from: peerID,
|
||||||
|
to: stablePeerID,
|
||||||
|
in: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiptIDs = context.privateMessages(for: peerID)
|
||||||
|
.filter { $0.senderPeerID == peerID }
|
||||||
|
.map(\.id)
|
||||||
|
context.unmarkReadReceiptsSent(receiptIDs)
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatTransportEventCoordinator {
|
private extension ChatTransportEventCoordinator {
|
||||||
|
@MainActor
|
||||||
|
func handlePublicMessage(
|
||||||
|
from peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
messageID: String?,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) {
|
||||||
|
let normalized = content.trimmed
|
||||||
|
let mentions = context.parseMentions(from: normalized)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: nickname,
|
||||||
|
content: normalized,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func handleReceivedMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
in context: any ChatTransportEventContext
|
||||||
|
) -> Bool {
|
||||||
|
guard !context.isMessageBlocked(message) else { return false }
|
||||||
|
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
|
||||||
|
|
||||||
|
if message.isPrivate {
|
||||||
|
context.handlePrivateMessage(message)
|
||||||
|
} else {
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
||||||
Task { @MainActor [weak context = self.context] in
|
Task { @MainActor [weak context = self.context] in
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
|
|||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||||
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
||||||
typealias Patterns = MessageFormattingEngine.Patterns
|
typealias Patterns = MessageFormattingEngine.Patterns
|
||||||
|
|
||||||
@@ -1701,7 +1701,81 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||||
receiveTransportEvent(event)
|
switch event {
|
||||||
|
case .messageReceived(let message):
|
||||||
|
_ = didReceiveTransportMessageSynchronously(message)
|
||||||
|
|
||||||
|
case let .publicMessageReceived(
|
||||||
|
peerID,
|
||||||
|
nickname,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
messageID
|
||||||
|
):
|
||||||
|
transportEventCoordinator.didReceivePublicMessageSynchronously(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
|
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
|
||||||
|
from: peerID,
|
||||||
|
type: type,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .groupMessageReceived(payload, timestamp):
|
||||||
|
groupCoordinator.handleGroupMessagePayload(
|
||||||
|
payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case let .publicVoiceFrameReceived(
|
||||||
|
peerID,
|
||||||
|
nickname,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
):
|
||||||
|
liveVoiceCoordinator.handlePublicVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case .peerConnected(let peerID):
|
||||||
|
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
case .peerDisconnected(let peerID):
|
||||||
|
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||||
|
|
||||||
|
case .peerListUpdated(let peers):
|
||||||
|
peerListCoordinator.didUpdatePeerListSynchronously(peers)
|
||||||
|
// A peer-list update follows every verified announce, which is
|
||||||
|
// where a peer's `.vouch` capability actually arrives.
|
||||||
|
vouchCoordinator.peersUpdated(peers)
|
||||||
|
|
||||||
|
case .peerSnapshotsUpdated:
|
||||||
|
break
|
||||||
|
|
||||||
|
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||||
|
deliveryCoordinator.didUpdateMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
|
||||||
|
case .bluetoothStateUpdated(let state):
|
||||||
|
updateBluetoothState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||||
|
transportEventCoordinator.didReceiveMessageSynchronously(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {
|
func didReceiveMessage(_ message: BitchatMessage) {
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
|
|||||||
/// no `ChatViewModel`.
|
/// no `ChatViewModel`.
|
||||||
struct ChatPeerListCoordinatorContextTests {
|
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
|
@Test @MainActor
|
||||||
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
||||||
let context = MockChatPeerListContext()
|
let context = MockChatPeerListContext()
|
||||||
|
|||||||
@@ -242,6 +242,30 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
#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
|
@Test @MainActor
|
||||||
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
|
func didReceivePublicMessage_trimsContentAndParsesMentions() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
@@ -295,6 +319,32 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
#expect(context.notifyUIChangedCount == 2)
|
#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
|
@Test @MainActor
|
||||||
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
||||||
let context = MockChatTransportEventContext()
|
let context = MockChatTransportEventContext()
|
||||||
|
|||||||
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
|
|||||||
|
|
||||||
struct ChatViewModelPeerTests {
|
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
|
@Test @MainActor
|
||||||
func didConnectToPeer_notifiesDelegate() async {
|
func didConnectToPeer_notifiesDelegate() async {
|
||||||
let (_, transport) = makeTestableViewModel()
|
let (_, transport) = makeTestableViewModel()
|
||||||
|
|||||||
Reference in New Issue
Block a user