mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Cut private message path over to ConversationStore
All private-message mutations now flow through store intents: coordinators, PrivateChatManager (its @Published dicts deleted - now read-only views over the store), outbound sends, delivery status, and chat migration. The O(1) store dedup replaces the full-scan duplicate check; insertion order is maintained by the store so sanitizeChat's re-sort is a documented no-op. Both bootstrapper Combine bridges and the Task.yield store synchronization are deleted. ChatViewModel.privateChats/unreadPrivateMessages become get-only derived views (measured: naive rebuild equals a change-invalidated cache within noise, so the simpler form stays). Feature models still read the legacy store, fed by a coalescing LegacyConversationStoreBridge (one mirror per burst, marked for step-5 deletion). pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -631,7 +631,7 @@ struct AppArchitectureTests {
|
||||
transport.reachablePeers.insert(otherPeerID)
|
||||
viewModel.nickname = "builder"
|
||||
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
||||
viewModel.unreadPrivateMessages = Set([otherPeerID])
|
||||
viewModel.markPrivateChatUnread(otherPeerID)
|
||||
transport.updatePeerSnapshots([
|
||||
makeArchitectureSnapshot(
|
||||
peerID: myPeerID,
|
||||
|
||||
@@ -38,9 +38,23 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
var nostrKeyMapping: [PeerID: String] = [:]
|
||||
private(set) var ownerLevelReadPasses: [PeerID] = []
|
||||
private(set) var managerReadMarks: [PeerID] = []
|
||||
private(set) var privateStoreSyncCount = 0
|
||||
private(set) var systemMessages: [String] = []
|
||||
|
||||
// Conversation store intents
|
||||
@discardableResult
|
||||
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
||||
var chat = privateChats[peerID] ?? []
|
||||
guard !chat.contains(where: { $0.id == message.id }) else { return false }
|
||||
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
|
||||
chat.insert(message, at: index)
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
func markPrivateChatRead(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||
sentReadReceipts.insert(messageID).inserted
|
||||
@@ -61,7 +75,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
work()
|
||||
}
|
||||
|
||||
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
|
||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||
|
||||
// Peers & sessions
|
||||
@@ -234,7 +247,6 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
coordinator.markPrivateMessagesAsRead(from: convKey)
|
||||
|
||||
#expect(context.managerReadMarks == [convKey])
|
||||
#expect(context.privateStoreSyncCount == 1)
|
||||
// Only the peer's own un-acked, non-relay message gets a READ.
|
||||
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
|
||||
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
|
||||
|
||||
@@ -42,6 +42,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
|
||||
// Message state
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
@discardableResult
|
||||
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
||||
var chat = privateChats[peerID] ?? []
|
||||
guard !chat.contains(where: { $0.id == message.id }) else { return false }
|
||||
chat.append(message)
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
var meshTimeline: [BitchatMessage] = []
|
||||
private(set) var refreshedChannels: [ChannelID?] = []
|
||||
private(set) var trimCount = 0
|
||||
|
||||
@@ -38,11 +38,34 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
|
||||
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||
|
||||
// Conversation store intents (mirror `ConversationStore` migrate
|
||||
// semantics: dedup by ID, timestamp order, unread carried, old chat
|
||||
// removed) while recording calls for assertions.
|
||||
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
|
||||
|
||||
func markPrivateChatRead(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
||||
migratedChats.append((oldPeerID, newPeerID))
|
||||
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
|
||||
var destination = privateChats[newPeerID] ?? []
|
||||
for message in source where !destination.contains(where: { $0.id == message.id }) {
|
||||
let index = destination.firstIndex(where: { $0.timestamp > message.timestamp }) ?? destination.count
|
||||
destination.insert(message, at: index)
|
||||
}
|
||||
privateChats[newPeerID] = destination
|
||||
privateChats.removeValue(forKey: oldPeerID)
|
||||
if unreadPrivateMessages.remove(oldPeerID) != nil {
|
||||
unreadPrivateMessages.insert(newPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Private chat session lifecycle
|
||||
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
|
||||
private(set) var syncedReadReceiptPeers: [PeerID] = []
|
||||
private(set) var begunChatSessions: [PeerID] = []
|
||||
private(set) var privateStoreSyncCount = 0
|
||||
private(set) var selectionStoreSyncCount = 0
|
||||
private(set) var markedReadPeers: [PeerID] = []
|
||||
|
||||
@@ -60,7 +83,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
|
||||
begunChatSessions.append(peerID)
|
||||
}
|
||||
|
||||
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
|
||||
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
|
||||
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
|
||||
|
||||
@@ -261,7 +283,6 @@ struct ChatPeerIdentityCoordinatorContextTests {
|
||||
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
|
||||
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
|
||||
#expect(context.begunChatSessions == [peerID])
|
||||
#expect(context.privateStoreSyncCount == 1)
|
||||
#expect(context.selectionStoreSyncCount == 1)
|
||||
#expect(context.markedReadPeers == [peerID])
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ private final class MockChatPeerListContext: ChatPeerListContext {
|
||||
private(set) var updatePrivateChatPeerIfNeededCount = 0
|
||||
private(set) var cleanupOldReadReceiptsCount = 0
|
||||
|
||||
func markPrivateChatRead(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
func updatePrivateChatPeerIfNeeded() {
|
||||
updatePrivateChatPeerIfNeededCount += 1
|
||||
}
|
||||
|
||||
@@ -48,6 +48,90 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
notifyUIChangedCount += 1
|
||||
}
|
||||
|
||||
// Conversation store intents (mirror `ConversationStore` semantics:
|
||||
// ordered insert, dedup by ID, no-downgrade status, unread carry on
|
||||
// migrate) while recording calls for assertions.
|
||||
private(set) var upsertedMessages: [(messageID: String, peerID: PeerID)] = []
|
||||
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
|
||||
|
||||
@discardableResult
|
||||
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
||||
var chat = privateChats[peerID] ?? []
|
||||
guard !chat.contains(where: { $0.id == message.id }) else {
|
||||
privateChats[peerID] = chat
|
||||
return false
|
||||
}
|
||||
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
|
||||
chat.insert(message, at: index)
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||
upsertedMessages.append((message.id, peerID))
|
||||
if var chat = privateChats[peerID],
|
||||
let index = chat.firstIndex(where: { $0.id == message.id }) {
|
||||
chat[index] = message
|
||||
privateChats[peerID] = chat
|
||||
} else {
|
||||
appendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
||||
guard var chat = privateChats[peerID],
|
||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
||||
return false
|
||||
}
|
||||
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
|
||||
return false
|
||||
}
|
||||
chat[index].deliveryStatus = status
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
func markPrivateChatUnread(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.insert(peerID)
|
||||
}
|
||||
|
||||
func markPrivateChatRead(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
func removePrivateChat(_ peerID: PeerID) {
|
||||
privateChats.removeValue(forKey: peerID)
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
||||
migratedChats.append((oldPeerID, newPeerID))
|
||||
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
|
||||
for message in source {
|
||||
appendPrivateMessage(message, to: newPeerID)
|
||||
}
|
||||
if privateChats[newPeerID] == nil {
|
||||
privateChats[newPeerID] = []
|
||||
}
|
||||
let wasUnread = unreadPrivateMessages.contains(oldPeerID)
|
||||
privateChats.removeValue(forKey: oldPeerID)
|
||||
unreadPrivateMessages.remove(oldPeerID)
|
||||
if wasUnread {
|
||||
unreadPrivateMessages.insert(newPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
func privateChatsContainMessage(withID messageID: String) -> Bool {
|
||||
privateChats.values.contains { chat in
|
||||
chat.contains { $0.id == messageID }
|
||||
}
|
||||
}
|
||||
|
||||
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool {
|
||||
privateChats[peerID]?.contains { $0.id == messageID } == true
|
||||
}
|
||||
|
||||
// Peers & identity
|
||||
var myPeerID = PeerID(str: "0011223344556677")
|
||||
var nicknamesByPeerID: [PeerID: String] = [:]
|
||||
@@ -149,10 +233,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
privateMessageNotifications.append((senderName, message, peerID))
|
||||
}
|
||||
|
||||
// System messages & chat hygiene
|
||||
// System messages
|
||||
private(set) var systemMessages: [String] = []
|
||||
private(set) var meshOnlySystemMessages: [String] = []
|
||||
private(set) var sanitizedPeerIDs: [PeerID] = []
|
||||
|
||||
func addSystemMessage(_ content: String) {
|
||||
systemMessages.append(content)
|
||||
@@ -162,10 +245,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
meshOnlySystemMessages.append(content)
|
||||
}
|
||||
|
||||
func sanitizeChat(for peerID: PeerID) {
|
||||
sanitizedPeerIDs.append(peerID)
|
||||
}
|
||||
|
||||
static let dummyIdentity = NostrIdentity(
|
||||
privateKey: Data(repeating: 0x11, count: 32),
|
||||
publicKey: Data(repeating: 0x22, count: 32),
|
||||
@@ -256,7 +335,9 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
// A different id appends.
|
||||
coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID)
|
||||
#expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"])
|
||||
#expect(context.sanitizedPeerIDs == [peerID, peerID, peerID])
|
||||
// Every add went through the store's upsert intent.
|
||||
#expect(context.upsertedMessages.map(\.peerID) == [peerID, peerID, peerID])
|
||||
#expect(context.upsertedMessages.map(\.messageID) == ["m1", "m1", "m2"])
|
||||
|
||||
#expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID))
|
||||
#expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID))
|
||||
@@ -436,7 +517,9 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.unreadPrivateMessages.isEmpty)
|
||||
#expect(context.clearedFingerprints == [oldPeerID])
|
||||
#expect(context.selectedPrivateChatPeer == newPeerID)
|
||||
#expect(context.sanitizedPeerIDs == [newPeerID])
|
||||
// The wholesale move went through the store's migrate intent.
|
||||
#expect(context.migratedChats.map(\.from) == [oldPeerID])
|
||||
#expect(context.migratedChats.map(\.to) == [newPeerID])
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
||||
private(set) var conversationActiveChannels: [ChannelID] = []
|
||||
private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = []
|
||||
private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = []
|
||||
private(set) var privateStoreSyncCount = 0
|
||||
private(set) var selectionStoreSyncCount = 0
|
||||
|
||||
func setConversationActiveChannel(_ channel: ChannelID) {
|
||||
@@ -126,10 +125,6 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
||||
replacedConversationMessages.append((conversationID, messages.map(\.id)))
|
||||
}
|
||||
|
||||
func synchronizePrivateConversationStore() {
|
||||
privateStoreSyncCount += 1
|
||||
}
|
||||
|
||||
func synchronizeConversationSelectionStore() {
|
||||
selectionStoreSyncCount += 1
|
||||
}
|
||||
@@ -137,8 +132,31 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
||||
// Private chats
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
var unreadPrivateMessages: Set<PeerID> = []
|
||||
private(set) var removedPrivateChats: [PeerID] = []
|
||||
private(set) var cleanedUpFileMessageIDs: [String] = []
|
||||
|
||||
func removePrivateChat(_ peerID: PeerID) {
|
||||
removedPrivateChats.append(peerID)
|
||||
privateChats.removeValue(forKey: peerID)
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||
var removed: BitchatMessage?
|
||||
for (peerID, chat) in privateChats {
|
||||
guard let message = chat.first(where: { $0.id == messageID }) else { continue }
|
||||
removed = removed ?? message
|
||||
let remaining = chat.filter { $0.id != messageID }
|
||||
if remaining.isEmpty {
|
||||
privateChats.removeValue(forKey: peerID)
|
||||
} else {
|
||||
privateChats[peerID] = remaining
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
cleanedUpFileMessageIDs.append(message.id)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,30 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
private(set) var unmarkedReadReceiptBatches: [[String]] = []
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
|
||||
// Conversation store intents (mirror `ConversationStore` semantics)
|
||||
@discardableResult
|
||||
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
||||
var chat = privateChats[peerID] ?? []
|
||||
guard !chat.contains(where: { $0.id == message.id }) else { return false }
|
||||
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
|
||||
chat.insert(message, at: index)
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
func removePrivateChat(_ peerID: PeerID) {
|
||||
privateChats.removeValue(forKey: peerID)
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
func markPrivateChatUnread(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.insert(peerID)
|
||||
}
|
||||
|
||||
func markPrivateChatRead(_ peerID: PeerID) {
|
||||
unreadPrivateMessages.remove(peerID)
|
||||
}
|
||||
|
||||
func unmarkReadReceiptsSent(_ ids: [String]) {
|
||||
unmarkedReadReceiptBatches.append(ids)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .read(by: "Peer", at: Date())
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
// Action: try to downgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
@@ -85,7 +85,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
// Action: upgrade to .delivered
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||
@@ -114,7 +114,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent)
|
||||
|
||||
@@ -140,7 +140,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
// Action: upgrade to .read
|
||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
||||
@@ -173,7 +173,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
// Action: receive read receipt
|
||||
let receipt = ReadReceipt(
|
||||
@@ -207,7 +207,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
||||
viewModel.isStartupPhase = false
|
||||
|
||||
@@ -265,7 +265,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[firstPeerID] = [
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
@@ -277,8 +277,8 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[secondPeerID] = [
|
||||
], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
@@ -290,7 +290,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
], for: secondPeerID)
|
||||
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
@@ -331,10 +331,15 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
|
||||
viewModel.privateChats[peerID] = [targetMessage, olderMessage]
|
||||
viewModel.seedPrivateChat([targetMessage], for: peerID)
|
||||
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
||||
|
||||
viewModel.privateChats[peerID] = [olderMessage, targetMessage]
|
||||
// A late arrival with an older timestamp is inserted before the
|
||||
// target by the store, shifting its position and invalidating the
|
||||
// delivery coordinator's location index.
|
||||
viewModel.seedPrivateChat([olderMessage], for: peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID])
|
||||
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: "Peer", at: Date())
|
||||
@@ -401,6 +406,20 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
markedDeliveredMessageIDs.append(messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
||||
guard var chat = privateChats[peerID],
|
||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
||||
return false
|
||||
}
|
||||
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
|
||||
return false
|
||||
}
|
||||
chat[index].deliveryStatus = status
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -177,7 +177,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
||||
isPrivate: true,
|
||||
senderPeerID: oldPeerID
|
||||
)
|
||||
viewModel.privateChats[oldPeerID] = [oldMessage]
|
||||
viewModel.seedPrivateChat([oldMessage], for: oldPeerID)
|
||||
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
|
||||
|
||||
// Setup new peer fingerprint
|
||||
@@ -444,7 +444,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "geo-ack-delivered"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
@@ -456,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
@@ -482,7 +482,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "geo-ack-read"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
@@ -494,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .delivered(to: "Friend", at: Date())
|
||||
)
|
||||
]
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
@@ -578,7 +578,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let messageID = "gift-delivered"
|
||||
|
||||
viewModel.privateChats[convKey] = [
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
@@ -590,7 +590,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
], for: convKey)
|
||||
|
||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
@@ -1095,7 +1095,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
|
||||
|
||||
viewModel.cancelMediaSend(messageID: message.id)
|
||||
@@ -1124,7 +1124,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: message.id)
|
||||
|
||||
@@ -136,7 +136,7 @@ struct ChatViewModelIdentityTests {
|
||||
senderPeerID: oldPeerID,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.privateChats[oldPeerID] = [existingMessage]
|
||||
viewModel.seedPrivateChat([existingMessage], for: oldPeerID)
|
||||
viewModel.startPrivateChat(with: oldPeerID)
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
|
||||
@@ -345,8 +345,8 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
mentions: nil
|
||||
)
|
||||
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.unreadPrivateMessages.insert(peerID)
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
viewModel.markPrivateChatUnread(peerID)
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleDidBecomeActive()
|
||||
@@ -497,7 +497,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: peerID,
|
||||
@@ -535,7 +535,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
mentions: nil,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.privateChats[peerID] = [message]
|
||||
viewModel.seedPrivateChat([message], for: peerID)
|
||||
|
||||
viewModel.didReceiveNoisePayload(
|
||||
from: peerID,
|
||||
@@ -771,7 +771,7 @@ struct ChatViewModelPeerTests {
|
||||
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let stalePeer = PeerID(str: "00000000000000a2")
|
||||
viewModel.unreadPrivateMessages = [stalePeer]
|
||||
viewModel.markPrivateChatUnread(stalePeer)
|
||||
|
||||
viewModel.didUpdatePeerList([])
|
||||
|
||||
@@ -798,8 +798,8 @@ struct ChatViewModelPeerTests {
|
||||
senderPeerID: stablePeer,
|
||||
mentions: nil
|
||||
)
|
||||
viewModel.privateChats[stablePeer] = [message]
|
||||
viewModel.unreadPrivateMessages = [stablePeer]
|
||||
viewModel.seedPrivateChat([message], for: stablePeer)
|
||||
viewModel.markPrivateChatUnread(stablePeer)
|
||||
|
||||
viewModel.didUpdatePeerList([])
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
@@ -876,33 +876,32 @@ struct ChatViewModelPrivateChatSelectionTests {
|
||||
let older = Date().addingTimeInterval(-120)
|
||||
let newer = Date().addingTimeInterval(-30)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
], for: peerA)
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
], for: peerB)
|
||||
viewModel.markPrivateChatUnread(peerA)
|
||||
viewModel.markPrivateChatUnread(peerB)
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
@@ -918,32 +917,30 @@ struct ChatViewModelPrivateChatSelectionTests {
|
||||
let older = Date().addingTimeInterval(-200)
|
||||
let newer = Date().addingTimeInterval(-20)
|
||||
|
||||
viewModel.privateChats = [
|
||||
peerA: [
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
],
|
||||
peerB: [
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
]
|
||||
]
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "a-1",
|
||||
sender: "A",
|
||||
content: "Old",
|
||||
timestamp: older,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerA
|
||||
)
|
||||
], for: peerA)
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "b-1",
|
||||
sender: "B",
|
||||
content: "New",
|
||||
timestamp: newer,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerB
|
||||
)
|
||||
], for: peerB)
|
||||
|
||||
viewModel.openMostRelevantPrivateChat()
|
||||
|
||||
@@ -1011,7 +1008,7 @@ struct ChatViewModelPanicTests {
|
||||
isRelay: false
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
@@ -1022,8 +1019,8 @@ struct ChatViewModelPanicTests {
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: PeerID(str: "PEER1")
|
||||
)
|
||||
]
|
||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
||||
], for: PeerID(str: "PEER1"))
|
||||
viewModel.markPrivateChatUnread(PeerID(str: "PEER1"))
|
||||
|
||||
viewModel.panicClearAllData()
|
||||
|
||||
|
||||
@@ -423,6 +423,12 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
clearCurrentPublicTimelineCallCount += 1
|
||||
}
|
||||
|
||||
private(set) var clearedPrivateChats: [PeerID] = []
|
||||
func clearPrivateChat(_ peerID: PeerID) {
|
||||
clearedPrivateChats.append(peerID)
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
|
||||
func sendPublicRaw(_ content: String) {
|
||||
sentPublicRawMessages.append(content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//
|
||||
// LegacyConversationStoreBridgeTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Focused tests for migration step 2 (docs/CONVERSATION-STORE-DESIGN.md §4):
|
||||
// the private write path goes through the single-writer `ConversationStore`
|
||||
// intents, and `LegacyConversationStoreBridge` keeps the replace-based
|
||||
// `LegacyConversationStore` consistent — per-message changes via a
|
||||
// `Task.yield`-coalesced per-conversation mirror, unread flips and
|
||||
// structural changes (migration/removal) synchronously — until the feature
|
||||
// models cut over in step 5.
|
||||
//
|
||||
// DELETE IN STEP 5 together with the bridge.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private func makeBridgedFixture() -> (
|
||||
viewModel: ChatViewModel,
|
||||
store: ConversationStore,
|
||||
legacy: LegacyConversationStore,
|
||||
transport: MockTransport
|
||||
) {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
let legacy = LegacyConversationStore()
|
||||
let store = ConversationStore()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport,
|
||||
conversationStore: legacy,
|
||||
conversations: store
|
||||
)
|
||||
return (viewModel, store, legacy, transport)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeInboundPrivateMessage(
|
||||
id: String,
|
||||
from peerID: PeerID,
|
||||
sender: String = "alice",
|
||||
content: String = "hello",
|
||||
timestamp: Date = Date()
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: sender,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: peerID,
|
||||
deliveryStatus: .delivered(to: "me", at: timestamp)
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("LegacyConversationStoreBridge")
|
||||
struct LegacyConversationStoreBridgeTests {
|
||||
|
||||
@Test("inbound private message lands in the store and is mirrored into Legacy")
|
||||
@MainActor
|
||||
func inboundPrivateMessageLandsInStoreAndLegacy() async {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
// Stable Noise-key peer ID, like the perf pipeline fixture.
|
||||
let peerID = PeerID(str: String(repeating: "ab", count: 32))
|
||||
let message = makeInboundPrivateMessage(id: "bridge-pm-1", from: peerID)
|
||||
|
||||
viewModel.handlePrivateMessage(message)
|
||||
|
||||
// New store is the synchronous source of truth.
|
||||
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["bridge-pm-1"])
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["bridge-pm-1"])
|
||||
// Unread flips mirror into Legacy synchronously.
|
||||
#expect(viewModel.unreadPrivateMessages.contains(peerID))
|
||||
#expect(legacy.unreadDirectPeerIDs().contains(peerID))
|
||||
|
||||
// Message arrays mirror via the bridge's coalesced flush
|
||||
// (one Legacy replace per burst, on the next main-actor turn).
|
||||
let mirrored = await TestHelpers.waitUntil(
|
||||
{ legacy.directMessagesByPeerID()[peerID]?.map(\.id) == ["bridge-pm-1"] },
|
||||
timeout: 1.0
|
||||
)
|
||||
#expect(mirrored)
|
||||
}
|
||||
|
||||
@Test("store dedups a private message delivered twice")
|
||||
@MainActor
|
||||
func storeDedupsDuplicateInboundMessage() async {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
let peerID = PeerID(str: String(repeating: "cd", count: 32))
|
||||
let message = makeInboundPrivateMessage(id: "bridge-dup-1", from: peerID)
|
||||
|
||||
viewModel.handlePrivateMessage(message)
|
||||
viewModel.handlePrivateMessage(message)
|
||||
|
||||
#expect(store.conversation(for: .directPeer(peerID)).messages.count == 1)
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
|
||||
// The intent itself reports the duplicate.
|
||||
#expect(!viewModel.appendPrivateMessage(message, to: peerID))
|
||||
|
||||
let mirrored = await TestHelpers.waitUntil(
|
||||
{ legacy.directMessagesByPeerID()[peerID]?.count == 1 },
|
||||
timeout: 1.0
|
||||
)
|
||||
#expect(mirrored)
|
||||
}
|
||||
|
||||
@Test("peer-ID migration through store intents keeps Legacy consistent")
|
||||
@MainActor
|
||||
func migrationThroughStoreIntentsKeepsLegacyConsistent() {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb")
|
||||
|
||||
viewModel.seedPrivateChat(
|
||||
[
|
||||
makeInboundPrivateMessage(id: "mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-60)),
|
||||
makeInboundPrivateMessage(id: "mig-2", from: oldPeerID, timestamp: Date().addingTimeInterval(-30)),
|
||||
],
|
||||
for: oldPeerID
|
||||
)
|
||||
viewModel.markPrivateChatUnread(oldPeerID)
|
||||
|
||||
viewModel.migratePrivateChat(from: oldPeerID, to: newPeerID)
|
||||
|
||||
// Store: messages moved in order, old chat gone, unread carried.
|
||||
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
|
||||
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["mig-1", "mig-2"])
|
||||
#expect(viewModel.unreadPrivateMessages == [newPeerID])
|
||||
|
||||
// Legacy: structural changes resynchronize immediately (stale
|
||||
// messages removed, destination present, unread re-keyed). The old
|
||||
// peer may linger as a registered-but-empty handle — Legacy has
|
||||
// always kept handle registrations from markRead/selection around.
|
||||
let legacyChats = legacy.directMessagesByPeerID()
|
||||
#expect(legacyChats[oldPeerID] ?? [] == [])
|
||||
#expect(legacyChats[newPeerID]?.map(\.id) == ["mig-1", "mig-2"])
|
||||
#expect(legacy.unreadDirectPeerIDs() == [newPeerID])
|
||||
}
|
||||
|
||||
@Test("coordinator chat migration uses the store migrate intent end to end")
|
||||
@MainActor
|
||||
func coordinatorMigrationFlowsThroughStore() {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
let oldPeerID = PeerID(str: "1111111111111111")
|
||||
let newPeerID = PeerID(str: "2222222222222222")
|
||||
|
||||
// Recent messages from "alice" under the old peer ID; no fingerprints
|
||||
// on either side → nickname-match migration path.
|
||||
viewModel.seedPrivateChat(
|
||||
[makeInboundPrivateMessage(id: "coord-mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-5))],
|
||||
for: oldPeerID
|
||||
)
|
||||
|
||||
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice")
|
||||
|
||||
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
|
||||
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["coord-mig-1"])
|
||||
// Migration resynchronizes Legacy immediately (the old peer may
|
||||
// linger as a registered-but-empty handle, see above).
|
||||
#expect(legacy.directMessagesByPeerID()[oldPeerID] ?? [] == [])
|
||||
#expect(legacy.directMessagesByPeerID()[newPeerID]?.map(\.id) == ["coord-mig-1"])
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,17 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
return oldCount - sentReadReceipts.count
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
||||
guard var chat = privateChats[peerID],
|
||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
||||
return false
|
||||
}
|
||||
chat[index].deliveryStatus = status
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
||||
/// deterministic IDs and timestamps.
|
||||
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// bitchatTests
|
||||
//
|
||||
// Tests for PrivateChatManager read receipt and selection behavior.
|
||||
// Message storage lives in the single-writer ConversationStore; the
|
||||
// manager's privateChats/unreadMessages are derived views over it.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@@ -12,13 +14,20 @@ import BitFoundation
|
||||
|
||||
struct PrivateChatManagerTests {
|
||||
|
||||
@MainActor
|
||||
private static func makeManager(transport: MockTransport) -> (PrivateChatManager, ConversationStore) {
|
||||
let store = ConversationStore()
|
||||
let manager = PrivateChatManager(meshService: transport, conversationStore: store)
|
||||
return (manager, store)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func startChat_setsSelectedAndClearsUnread() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
let peerID = PeerID(str: "00000000000000AA")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "pm-1",
|
||||
sender: "Peer",
|
||||
@@ -28,9 +37,10 @@ struct PrivateChatManagerTests {
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
store.markUnread(.directPeer(peerID))
|
||||
|
||||
manager.startChat(with: peerID)
|
||||
|
||||
@@ -43,13 +53,13 @@ struct PrivateChatManagerTests {
|
||||
func markAsRead_sendsReadReceiptViaRouter() async {
|
||||
let transport = MockTransport()
|
||||
let router = MessageRouter(transports: [transport])
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
manager.messageRouter = router
|
||||
|
||||
let peerID = PeerID(str: "00000000000000BB")
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "pm-2",
|
||||
sender: "Peer",
|
||||
@@ -59,9 +69,10 @@ struct PrivateChatManagerTests {
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(peerID)
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
store.markUnread(.directPeer(peerID))
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
@@ -74,10 +85,10 @@ struct PrivateChatManagerTests {
|
||||
@Test @MainActor
|
||||
func markAsRead_withoutRouterFallsBackToTransport() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
let peerID = PeerID(str: "00000000000000CC")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "pm-fallback",
|
||||
sender: "Peer",
|
||||
@@ -87,8 +98,9 @@ struct PrivateChatManagerTests {
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
|
||||
manager.markAsRead(from: peerID)
|
||||
|
||||
@@ -99,7 +111,7 @@ struct PrivateChatManagerTests {
|
||||
@Test @MainActor
|
||||
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)
|
||||
@@ -120,7 +132,7 @@ struct PrivateChatManagerTests {
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
|
||||
manager.privateChats[stablePeerID] = [
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "stable-msg",
|
||||
sender: "Alice",
|
||||
@@ -130,9 +142,10 @@ struct PrivateChatManagerTests {
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: stablePeerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(stablePeerID)
|
||||
),
|
||||
to: .directPeer(stablePeerID)
|
||||
)
|
||||
store.markUnread(.directPeer(stablePeerID))
|
||||
|
||||
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: [])
|
||||
|
||||
@@ -146,11 +159,11 @@ struct PrivateChatManagerTests {
|
||||
@Test @MainActor
|
||||
func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042")
|
||||
|
||||
manager.privateChats[tempPeerID] = [
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "geo-msg",
|
||||
sender: "Alice",
|
||||
@@ -160,9 +173,10 @@ struct PrivateChatManagerTests {
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: tempPeerID
|
||||
)
|
||||
]
|
||||
manager.unreadMessages.insert(tempPeerID)
|
||||
),
|
||||
to: .directPeer(tempPeerID)
|
||||
)
|
||||
store.markUnread(.directPeer(tempPeerID))
|
||||
|
||||
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: [])
|
||||
|
||||
@@ -177,10 +191,10 @@ struct PrivateChatManagerTests {
|
||||
@Test @MainActor
|
||||
func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
let peerID = PeerID(str: "00000000000000DD")
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
let seeded = [
|
||||
BitchatMessage(
|
||||
id: "sent-read",
|
||||
sender: "Me",
|
||||
@@ -215,6 +229,9 @@ struct PrivateChatManagerTests {
|
||||
deliveryStatus: .failed(reason: "nope")
|
||||
)
|
||||
]
|
||||
for message in seeded {
|
||||
store.append(message, to: .directPeer(peerID))
|
||||
}
|
||||
|
||||
var externalReceipts = Set<String>()
|
||||
manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts)
|
||||
@@ -223,47 +240,36 @@ struct PrivateChatManagerTests {
|
||||
#expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"]))
|
||||
}
|
||||
|
||||
/// The store replaces `sanitizeChat`: inserts keep chronological order,
|
||||
/// duplicate IDs are rejected on append, and `upsertByID` replaces the
|
||||
/// stored message with the latest copy in place.
|
||||
@Test @MainActor
|
||||
func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async {
|
||||
func store_keepsChronologicalOrderAndDedupsByID() async {
|
||||
let transport = MockTransport()
|
||||
let manager = PrivateChatManager(meshService: transport)
|
||||
let (manager, store) = Self.makeManager(transport: transport)
|
||||
let peerID = PeerID(str: "00000000000000EE")
|
||||
let base = Date(timeIntervalSince1970: 10)
|
||||
|
||||
manager.privateChats[peerID] = [
|
||||
func message(_ id: String, _ content: String, offset: TimeInterval) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: "same",
|
||||
id: id,
|
||||
sender: "Peer",
|
||||
content: "Older",
|
||||
timestamp: base.addingTimeInterval(10),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "first",
|
||||
sender: "Peer",
|
||||
content: "First",
|
||||
timestamp: base,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "same",
|
||||
sender: "Peer",
|
||||
content: "Newest",
|
||||
timestamp: base.addingTimeInterval(20),
|
||||
content: content,
|
||||
timestamp: base.addingTimeInterval(offset),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
manager.sanitizeChat(for: peerID)
|
||||
#expect(store.append(message("same", "Older", offset: 10), to: .directPeer(peerID)))
|
||||
// Out-of-order arrival is inserted in timestamp order.
|
||||
#expect(store.append(message("first", "First", offset: 0), to: .directPeer(peerID)))
|
||||
// Duplicate ID is rejected on append…
|
||||
#expect(!store.append(message("same", "Newest", offset: 20), to: .directPeer(peerID)))
|
||||
// …and replaced in place by upsert.
|
||||
store.upsertByID(message("same", "Newest", offset: 20), in: .directPeer(peerID))
|
||||
|
||||
#expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"])
|
||||
#expect(manager.privateChats[peerID]?.last?.content == "Newest")
|
||||
|
||||
@@ -138,6 +138,28 @@ enum TestError: Error {
|
||||
case testFailure(String)
|
||||
}
|
||||
|
||||
// MARK: - Private chat seeding (ConversationStore migration)
|
||||
|
||||
extension ChatViewModel {
|
||||
/// Test-only replacement for the deleted `privateChats` setter: seeds a
|
||||
/// peer's chat through the single-writer `ConversationStore` intents
|
||||
/// (upsert keeps re-seeding with updated copies working the way the old
|
||||
/// dictionary assignment did).
|
||||
@MainActor
|
||||
func seedPrivateChat(_ messages: [BitchatMessage], for peerID: PeerID) {
|
||||
_ = conversations.conversation(for: .directPeer(peerID))
|
||||
for message in messages {
|
||||
conversations.upsertByID(message, in: .directPeer(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: drops every private chat and unread flag.
|
||||
@MainActor
|
||||
func clearAllPrivateChats() {
|
||||
conversations.removeAllDirectConversations()
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(_ seconds: TimeInterval) async throws {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ struct ViewSmokeTests {
|
||||
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
|
||||
])
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
viewModel.unreadPrivateMessages.insert(blockedPeer)
|
||||
viewModel.markPrivateChatUnread(blockedPeer)
|
||||
|
||||
_ = mount(
|
||||
MeshPeerList(
|
||||
|
||||
Reference in New Issue
Block a user