diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 65fcaebf..a0710673 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -66,12 +66,12 @@ actor AppEventStream { } } +/// Identity key for a direct conversation. Equality and hashing use the +/// canonical `id` only; `routingPeerID` carries the transport-level peer ID +/// the conversation is keyed under (see `ConversationID.directPeer`). struct PeerHandle: Sendable, Identifiable { let id: String let routingPeerID: PeerID - let displayName: String? - let noisePublicKeyHex: String? - let nostrPublicKey: String? } extension PeerHandle: Equatable { @@ -100,310 +100,3 @@ enum ConversationID: Hashable, Sendable { } } } - -@MainActor -final class IdentityResolver { - private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:] - private var handlesByNoiseKey: [String: PeerHandle] = [:] - private var handlesByNostrKey: [String: PeerHandle] = [:] - - func register(peers: [BitchatPeer]) { - for peer in peers { - _ = register(peer: peer) - } - } - - @discardableResult - func register(peer: BitchatPeer) -> PeerHandle { - let handle = buildHandle( - routingPeerID: peer.peerID, - displayName: peer.displayName, - noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(), - nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey) - ) - cache(handle) - return handle - } - - func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle { - if let handle = handlesByRoutingPeerID[peerID] { - return handle - } - - if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] { - return handle - } - - if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] { - return handle - } - - let handle = buildHandle( - routingPeerID: peerID, - displayName: displayName, - noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil, - nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil - ) - cache(handle) - return handle - } - - private func buildHandle( - routingPeerID: PeerID, - displayName: String?, - noisePublicKeyHex: String?, - nostrPublicKey: String? - ) -> PeerHandle { - let canonicalID: String - if let noisePublicKeyHex { - canonicalID = "noise:\(noisePublicKeyHex)" - } else if let nostrPublicKey { - canonicalID = "nostr:\(nostrPublicKey)" - } else { - canonicalID = "mesh:\(routingPeerID.id)" - } - - let normalizedDisplayName: String? - if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - normalizedDisplayName = displayName - } else { - normalizedDisplayName = nil - } - - return PeerHandle( - id: canonicalID, - routingPeerID: routingPeerID, - displayName: normalizedDisplayName, - noisePublicKeyHex: noisePublicKeyHex, - nostrPublicKey: nostrPublicKey - ) - } - - private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? { - guard let nostrPublicKey else { return nil } - let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed.isEmpty ? nil : trimmed - } - - private func cache(_ handle: PeerHandle) { - handlesByRoutingPeerID[handle.routingPeerID] = handle - if let noisePublicKeyHex = handle.noisePublicKeyHex { - handlesByNoiseKey[noisePublicKeyHex] = handle - } - if let nostrPublicKey = handle.nostrPublicKey { - handlesByNostrKey[nostrPublicKey] = handle - } - } -} - -@MainActor -final class LegacyConversationStore: ObservableObject { - @Published private(set) var activeChannel: ChannelID = .mesh - @Published private(set) var selectedPrivatePeerID: PeerID? - @Published private(set) var selectedConversationID: ConversationID = .mesh - @Published private(set) var unreadConversations: Set = [] - @Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:] - - private var directHandlesByConversation: [ConversationID: PeerHandle] = [:] - - func setActiveChannel(_ channelID: ChannelID) { - if activeChannel != channelID { - activeChannel = channelID - } - if selectedPrivatePeerID == nil { - let conversationID = ConversationID(channelID: channelID) - if selectedConversationID != conversationID { - selectedConversationID = conversationID - } - } - } - - func setSelectedPeerID( - _ peerID: PeerID?, - activeChannel: ChannelID, - identityResolver: IdentityResolver - ) { - if self.activeChannel != activeChannel { - self.activeChannel = activeChannel - } - if selectedPrivatePeerID != peerID { - selectedPrivatePeerID = peerID - } - - if let peerID { - let conversationID = directConversationID( - for: peerID, - identityResolver: identityResolver - ) - if selectedConversationID != conversationID { - selectedConversationID = conversationID - } - } else { - let conversationID = ConversationID(channelID: activeChannel) - if selectedConversationID != conversationID { - selectedConversationID = conversationID - } - } - } - - func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { - let normalizedMessages = normalized(messages) - guard messagesByConversation[conversationID] != normalizedMessages else { return } - messagesByConversation[conversationID] = normalizedMessages - } - - func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { - replaceMessages(messages, for: ConversationID(channelID: channelID)) - } - - func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) { - setActiveChannel(activeChannel) - replaceMessages(messages, for: activeChannel) - } - - func messages(for conversationID: ConversationID) -> [BitchatMessage] { - messagesByConversation[conversationID] ?? [] - } - - func directMessages( - for peerID: PeerID, - identityResolver: IdentityResolver - ) -> [BitchatMessage] { - messages(for: directConversationID(for: peerID, identityResolver: identityResolver)) - } - - func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] { - var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] - - for (conversationID, handle) in directHandlesByConversation { - messagesByPeerID[handle.routingPeerID] = messages(for: conversationID) - } - - return messagesByPeerID - } - - func unreadDirectPeerIDs() -> Set { - unreadConversations.reduce(into: Set()) { result, conversationID in - guard case .direct(let handle) = conversationID else { return } - result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID) - } - } - - func synchronizeSelection( - activeChannel: ChannelID, - selectedPeerID: PeerID?, - identityResolver: IdentityResolver - ) { - setSelectedPeerID( - selectedPeerID, - activeChannel: activeChannel, - identityResolver: identityResolver - ) - } - - func synchronizePrivateChats( - _ privateChats: [PeerID: [BitchatMessage]], - unreadPeerIDs: Set, - identityResolver: IdentityResolver - ) { - var liveConversations = Set() - - for (peerID, messages) in privateChats { - let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender) - let conversationID = ConversationID.direct(handle) - liveConversations.insert(conversationID) - directHandlesByConversation[conversationID] = handle - replaceMessages(messages, for: conversationID) - } - - let staleDirectConversations = messagesByConversation.keys.filter { conversationID in - guard case .direct = conversationID else { return false } - return !liveConversations.contains(conversationID) - } - - for conversationID in staleDirectConversations { - messagesByConversation.removeValue(forKey: conversationID) - unreadConversations.remove(conversationID) - directHandlesByConversation.removeValue(forKey: conversationID) - } - - let publicUnread = unreadConversations.filter { conversationID in - switch conversationID { - case .mesh, .geohash: - return true - case .direct: - return false - } - } - - let nextUnreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in - let handle = identityResolver.canonicalHandle(for: peerID) - result.insert(.direct(handle)) - } - if unreadConversations != nextUnreadConversations { - unreadConversations = nextUnreadConversations - } - } - - func markRead(_ conversationID: ConversationID) { - unreadConversations.remove(conversationID) - } - - func markRead( - peerID: PeerID, - identityResolver: IdentityResolver - ) { - markRead(directConversationID(for: peerID, identityResolver: identityResolver)) - } - - // MARK: Migration step 2 bridge entry points (DELETE IN STEP 5) - // Used only by `LegacyConversationStoreBridge` to mirror single - // conversations out of the new `ConversationStore` without the - // full-dictionary `synchronizePrivateChats` pass. - - func replaceDirectMessages( - _ messages: [BitchatMessage], - for peerID: PeerID, - identityResolver: IdentityResolver - ) { - let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender) - let conversationID = ConversationID.direct(handle) - directHandlesByConversation[conversationID] = handle - replaceMessages(messages, for: conversationID) - } - - func markUnread( - peerID: PeerID, - identityResolver: IdentityResolver - ) { - let conversationID = directConversationID(for: peerID, identityResolver: identityResolver) - if !unreadConversations.contains(conversationID) { - unreadConversations.insert(conversationID) - } - } - - private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] { - var uniqueMessages: [String: BitchatMessage] = [:] - - for message in messages { - uniqueMessages[message.id] = message - } - - return uniqueMessages.values.sorted { lhs, rhs in - if lhs.timestamp != rhs.timestamp { - return lhs.timestamp < rhs.timestamp - } - return lhs.id < rhs.id - } - } - - private func directConversationID( - for peerID: PeerID, - identityResolver: IdentityResolver - ) -> ConversationID { - let handle = identityResolver.canonicalHandle(for: peerID) - let conversationID = ConversationID.direct(handle) - directHandlesByConversation[conversationID] = handle - return conversationID - } -} diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index db861132..071115d5 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -14,11 +14,9 @@ import AppKit final class AppRuntime: ObservableObject { let chatViewModel: ChatViewModel let events = AppEventStream() - let conversationStore: LegacyConversationStore - /// Single source of truth for conversation message state - /// (docs/CONVERSATION-STORE-DESIGN.md). The legacy store above keeps - /// feeding the feature models until step 5, mirrored from this one by - /// `LegacyConversationStoreBridge`. + /// Single source of truth for conversation message state and selection + /// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models + /// and `ChatViewModel` observe and mutate it through its intent API. let conversations: ConversationStore let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore @@ -47,13 +45,10 @@ final class AppRuntime: ObservableObject { idBridge: NostrIdentityBridge = NostrIdentityBridge() ) { self.idBridge = idBridge - let identityResolver = IdentityResolver() - let conversationStore = LegacyConversationStore() let conversations = ConversationStore() let peerIdentityStore = PeerIdentityStore() let locationPresenceStore = LocationPresenceStore() let locationManager = LocationChannelManager.shared - self.conversationStore = conversationStore self.conversations = conversations self.peerIdentityStore = peerIdentityStore self.locationPresenceStore = locationPresenceStore @@ -61,19 +56,17 @@ final class AppRuntime: ObservableObject { keychain: keychain, idBridge: idBridge, identityManager: SecureIdentityStateManager(keychain), - conversationStore: conversationStore, conversations: conversations, - identityResolver: identityResolver, peerIdentityStore: peerIdentityStore, locationPresenceStore: locationPresenceStore, locationManager: locationManager ) - self.publicChatModel = PublicChatModel(conversationStore: conversationStore) - self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + self.publicChatModel = PublicChatModel(conversations: conversations) + self.privateInboxModel = PrivateInboxModel(conversations: conversations) self.locationChannelsModel = LocationChannelsModel(manager: locationManager) self.privateConversationModel = PrivateConversationModel( chatViewModel: self.chatViewModel, - conversationStore: conversationStore, + conversations: conversations, locationChannelsModel: self.locationChannelsModel, peerIdentityStore: peerIdentityStore ) @@ -85,11 +78,11 @@ final class AppRuntime: ObservableObject { self.conversationUIModel = ConversationUIModel( chatViewModel: self.chatViewModel, privateConversationModel: self.privateConversationModel, - conversationStore: conversationStore + conversations: conversations ) self.peerListModel = PeerListModel( chatViewModel: self.chatViewModel, - conversationStore: conversationStore, + conversations: conversations, locationChannelsModel: self.locationChannelsModel, peerIdentityStore: peerIdentityStore, locationPresenceStore: locationPresenceStore @@ -226,7 +219,7 @@ final class AppRuntime: ObservableObject { userInfo: [AnyHashable: Any] ) async -> UNNotificationPresentationOptions { if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { - if conversationStore.selectedPrivatePeerID == peerID { + if conversations.selectedPrivatePeerID == peerID { return [] } return [.banner, .sound] diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index f7fff03a..da7a8dee 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -7,9 +7,9 @@ // `ConversationID`; all mutations flow through the store's intent API and // every mutation emits a `ConversationChange` after state is consistent. // -// During migration the previous replace-based store lives on as -// `LegacyConversationStore` (AppArchitecture.swift) and is deleted in the -// final migration step. +// The store also owns conversation selection: the active public channel and +// the selected private peer (the two UI selection axes) plus the derived +// `selectedConversationID`. // // This is free and unencumbered software released into the public domain. // For more information, see @@ -257,6 +257,16 @@ final class ConversationStore: ObservableObject { @Published private(set) var selectedConversationID: ConversationID? @Published private(set) var unreadConversations: Set = [] + // MARK: Selection state + // The two UI selection axes: which public channel is active, and which + // private chat (if any) is open on top of it. `selectedConversationID` + // is derived: the open private chat wins, otherwise the active public + // channel's conversation. Mutate via `setActiveChannel` / + // `setSelectedPrivatePeer` only. + + @Published private(set) var activeChannel: ChannelID = .mesh + @Published private(set) var selectedPrivatePeerID: PeerID? + private(set) var conversationsByID: [ConversationID: Conversation] = [:] /// Store-level message-ID → conversation-membership map for ID-only @@ -391,6 +401,32 @@ final class ConversationStore: ObservableObject { selectedConversationID = id } + /// Switches the active public channel. While no private chat is open + /// the selection follows the channel. + func setActiveChannel(_ channelID: ChannelID) { + if activeChannel != channelID { + activeChannel = channelID + } + refreshDerivedSelection() + } + + /// Opens a private chat (`nil` closes it, returning the selection to the + /// active public channel's conversation). + func setSelectedPrivatePeer(_ peerID: PeerID?) { + if selectedPrivatePeerID != peerID { + selectedPrivatePeerID = peerID + } + refreshDerivedSelection() + } + + private func refreshDerivedSelection() { + if let peerID = selectedPrivatePeerID { + select(.directPeer(peerID)) + } else { + select(ConversationID(channelID: activeChannel)) + } + } + /// Moves all messages from `source` into `destination` (the /// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves /// timestamp order, carries unread state over, and hands off the @@ -424,6 +460,13 @@ final class ConversationStore: ObservableObject { } if wasSelected { selectedConversationID = destination + // Keep the private-peer selection axis consistent with the + // handed-off selection. + if let peerID = selectedPrivatePeerID, + source == .directPeer(peerID), + case .direct(let destinationHandle) = destination { + selectedPrivatePeerID = destinationHandle.routingPeerID + } } changes.send(.migrated(from: source, to: destination)) @@ -532,32 +575,27 @@ final class ConversationStore: ObservableObject { } } -// MARK: - Migration step 2 compatibility (raw per-peer keying + derived views) +// MARK: - Direct-conversation keying + derived views extension ConversationID { /// Direct-conversation ID keyed by the *raw* routing peer ID. /// - /// Migration step 2 keeps one conversation per `PeerID` — exactly the - /// buckets the legacy `privateChats` dictionary had — so the - /// ephemeral/stable mirroring and consolidation coordinators keep their - /// current semantics. Step 5 canonicalizes direct conversations through - /// `IdentityResolver` and this helper goes away. + /// Direct conversations are deliberately keyed per `PeerID`, not per + /// resolved identity: the private-chat coordinators mirror messages into + /// both the ephemeral and stable peer's conversations + /// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them + /// explicitly, so a raw lookup by whichever peer ID is selected always + /// finds the right timeline without an identity-resolution layer. static func directPeer(_ peerID: PeerID) -> ConversationID { - .direct(PeerHandle( - id: "peer:\(peerID.id)", - routingPeerID: peerID, - displayName: nil, - noisePublicKeyHex: nil, - nostrPublicKey: nil - )) + .direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID)) } } extension ConversationStore { /// All direct conversations' messages keyed by routing peer ID — the - /// compat shape of the legacy `privateChats` dictionary. Values are the - /// conversations' backing arrays (COW), so building this is - /// O(#conversations), not O(#messages). + /// shape `ChatViewModel.privateChats` exposes to the coordinators. + /// Values are the conversations' backing arrays (COW), so building this + /// is O(#conversations), not O(#messages). func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] { var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] messagesByPeerID.reserveCapacity(conversationsByID.count) @@ -568,8 +606,8 @@ extension ConversationStore { return messagesByPeerID } - /// Unread direct conversations as routing peer IDs — the compat shape of - /// the legacy `unreadPrivateMessages` set. + /// Unread direct conversations as routing peer IDs — the shape + /// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators. func unreadDirectRoutingPeerIDs() -> Set { var peerIDs = Set() for id in unreadConversations { @@ -611,13 +649,11 @@ extension ConversationStore { } } -// MARK: - Migration step 3 compatibility (public timeline derived views) +// MARK: - Public timeline derived views extension ConversationStore { /// Removes a message by ID from whichever public (mesh/geohash) - /// conversation contains it — the compat shape of the legacy - /// `PublicTimelineStore.removeMessage(withID:)`. Returns the removed - /// message, if any. + /// conversation contains it. Returns the removed message, if any. @discardableResult func removePublicMessage(withID messageID: String) -> BitchatMessage? { for id in conversationIDs(forMessageID: messageID) { diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 120077fb..29770e1a 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -15,19 +15,19 @@ final class ConversationUIModel: ObservableObject { private let chatViewModel: ChatViewModel private let privateConversationModel: PrivateConversationModel - private let conversationStore: LegacyConversationStore + private let conversations: ConversationStore private var activeChannel: ChannelID private var cancellables = Set() init( chatViewModel: ChatViewModel, privateConversationModel: PrivateConversationModel, - conversationStore: LegacyConversationStore + conversations: ConversationStore ) { self.chatViewModel = chatViewModel self.privateConversationModel = privateConversationModel - self.conversationStore = conversationStore - self.activeChannel = conversationStore.activeChannel + self.conversations = conversations + self.activeChannel = conversations.activeChannel self.currentNickname = chatViewModel.nickname self.isBatchingPublic = chatViewModel.isBatchingPublic self.showAutocomplete = chatViewModel.showAutocomplete @@ -151,7 +151,7 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$isBatchingPublic) - conversationStore.$activeChannel + conversations.$activeChannel .receive(on: DispatchQueue.main) .sink { [weak self] channel in self?.activeChannel = channel diff --git a/bitchat/App/LegacyConversationStoreBridge.swift b/bitchat/App/LegacyConversationStoreBridge.swift deleted file mode 100644 index cf412298..00000000 --- a/bitchat/App/LegacyConversationStoreBridge.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// LegacyConversationStoreBridge.swift -// bitchat -// -// Migration step 2 adapter (DELETE IN STEP 5, see -// docs/CONVERSATION-STORE-DESIGN.md §4). -// -// The new `ConversationStore` is the single writer for private (direct) AND -// public (mesh/geohash) message state; the feature models -// (`PrivateInboxModel`, `PrivateConversationModel`, `ConversationUIModel`, -// `PeerListModel`, `PublicChatModel`) still read the replace-based -// `LegacyConversationStore` until step 5. This bridge keeps Legacy fed from -// the new store's `changes` subject: per-message changes mark the affected -// conversation dirty and a `Task.yield`-coalesced flush mirrors only the -// dirty conversations — a burst of N appends costs ONE Legacy replace (like -// the old debounced sync) without the full-dict pass. Structural direct -// changes (migration/removal) resynchronize immediately; public removals -// mirror an empty timeline. Legacy is therefore eventually consistent within -// one run-loop tick — the same visibility the old sinks and per-message -// public syncs provided — while the new store stays synchronously -// authoritative. -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import BitFoundation -import Combine -import Foundation - -@MainActor -final class LegacyConversationStoreBridge { - private let store: ConversationStore - private let legacyStore: LegacyConversationStore - private let identityResolver: IdentityResolver - private var cancellable: AnyCancellable? - - private var dirtyConversations: Set = [] - private var pendingFlushTask: Task? - - init( - store: ConversationStore, - legacyStore: LegacyConversationStore, - identityResolver: IdentityResolver - ) { - self.store = store - self.legacyStore = legacyStore - self.identityResolver = identityResolver - - cancellable = store.changes.sink { [weak self] change in - self?.apply(change) - } - } - - /// Full resynchronization of every direct conversation into Legacy. - /// - /// Needed when `IdentityResolver` learns new peer associations (the - /// canonical handle for an existing conversation can change, re-keying - /// it in Legacy) and after structural store changes. This is the old - /// `synchronizePrivateChats` full pass — acceptable because it only runs - /// on peer-list changes and rare migrations, never per message. - func resynchronizeAll() { - // The full pass covers every direct conversation; pending direct - // per-conversation work is redundant. Dirty public conversations - // keep their scheduled mirror. - dirtyConversations = dirtyConversations.filter { !isDirect($0) } - legacyStore.synchronizePrivateChats( - store.directMessagesByRoutingPeerID(), - unreadPeerIDs: store.unreadDirectRoutingPeerIDs(), - identityResolver: identityResolver - ) - } -} - -private extension LegacyConversationStoreBridge { - func apply(_ change: ConversationChange) { - switch change { - case .appended(let id, _), - .updated(let id, _), - .statusChanged(let id, _, _), - .messageRemoved(let id, _), - .cleared(let id): - markDirty(id) - - case .unreadChanged(let id, let isUnread): - guard case .direct(let handle) = id else { return } - if isUnread { - legacyStore.markUnread(peerID: handle.routingPeerID, identityResolver: identityResolver) - } else { - legacyStore.markRead(peerID: handle.routingPeerID, identityResolver: identityResolver) - } - - case .migrated(let source, let destination): - guard isDirect(source) || isDirect(destination) else { return } - resynchronizeAll() - - case .removed(let id): - if isDirect(id) { - resynchronizeAll() - } else { - // Public conversation removed (panic clear): mirror an empty - // timeline immediately so Legacy readers never show stale - // messages. - dirtyConversations.remove(id) - legacyStore.replaceMessages([], for: id) - } - } - } - - func markDirty(_ id: ConversationID) { - dirtyConversations.insert(id) - scheduleFlush() - } - - /// One pending flush at a time, exactly like the old - /// `schedulePrivateConversationStoreSynchronization` debounce: a - /// synchronous burst of mutations coalesces into a single flush on the - /// next main-actor turn. - func scheduleFlush() { - guard pendingFlushTask == nil else { return } - pendingFlushTask = Task { @MainActor [weak self] in - await Task.yield() - guard let self else { return } - self.pendingFlushTask = nil - self.flushDirtyConversations() - } - } - - func flushDirtyConversations() { - guard !dirtyConversations.isEmpty else { return } - let dirty = dirtyConversations - dirtyConversations.removeAll() - for id in dirty { - mirrorConversation(id) - } - } - - func mirrorConversation(_ id: ConversationID) { - guard let conversation = store.conversationsByID[id] else { - // Removed while dirty; the removal already resynchronized - // (direct) or mirrored an empty timeline (public). - return - } - switch id { - case .direct(let handle): - legacyStore.replaceDirectMessages( - conversation.messages, - for: handle.routingPeerID, - identityResolver: identityResolver - ) - case .mesh, .geohash: - legacyStore.replaceMessages(conversation.messages, for: id) - } - } - - func isDirect(_ id: ConversationID) -> Bool { - if case .direct = id { return true } - return false - } -} diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 90a8ea2b..38f20a1c 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -37,7 +37,7 @@ final class PeerListModel: ObservableObject { @Published private(set) var renderID = "" private let chatViewModel: ChatViewModel - private let conversationStore: LegacyConversationStore + private let conversations: ConversationStore private let locationChannelsModel: LocationChannelsModel private let peerIdentityStore: PeerIdentityStore private let locationPresenceStore: LocationPresenceStore @@ -45,13 +45,13 @@ final class PeerListModel: ObservableObject { init( chatViewModel: ChatViewModel, - conversationStore: LegacyConversationStore, + conversations: ConversationStore, locationChannelsModel: LocationChannelsModel? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil ) { self.chatViewModel = chatViewModel - self.conversationStore = conversationStore + self.conversations = conversations self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore @@ -122,7 +122,7 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) - conversationStore.$unreadConversations + conversations.$unreadConversations .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.refresh() diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index cf50037f..71871984 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -2,69 +2,93 @@ import BitFoundation import Combine import Foundation +/// Feature model for private (direct) conversations. +/// +/// Reads the single-writer `ConversationStore` directly: `messages(for:)` +/// returns the peer's conversation backing array (no mirror dictionary), and +/// the store's typed `changes` subject drives invalidation — a change in the +/// SELECTED peer's conversation republishes this model, while appends to +/// other private chats only surface through the unread set. Direct +/// conversations are keyed by raw routing peer ID; the coordinators' +/// ephemeral/stable mirroring guarantees the selected peer's key always +/// holds the full timeline (see `ConversationID.directPeer`). @MainActor final class PrivateInboxModel: ObservableObject { @Published private(set) var selectedPeerID: PeerID? @Published private(set) var unreadPeerIDs: Set = [] - @Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] - private let conversationStore: LegacyConversationStore + private let conversations: ConversationStore private var cancellables = Set() - init(conversationStore: LegacyConversationStore) { - self.conversationStore = conversationStore + init(conversations: ConversationStore) { + self.conversations = conversations + self.selectedPeerID = conversations.selectedPrivatePeerID + self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs() bind() - refreshMessages() } func messages(for peerID: PeerID?) -> [BitchatMessage] { guard let peerID else { return [] } - return messagesByPeerID[peerID] ?? [] + return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] } private func bind() { - conversationStore.$selectedPrivatePeerID - .receive(on: DispatchQueue.main) + conversations.$selectedPrivatePeerID + .dropFirst() .sink { [weak self] peerID in - self?.selectedPeerID = peerID - self?.refreshMessages() + guard let self, self.selectedPeerID != peerID else { return } + self.selectedPeerID = peerID } .store(in: &cancellables) - conversationStore.$unreadConversations - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? [] - self?.refreshMessages() + conversations.changes + .sink { [weak self] change in + self?.apply(change) } .store(in: &cancellables) - - conversationStore.$messagesByConversation - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshMessages() - } - .store(in: &cancellables) - - selectedPeerID = conversationStore.selectedPrivatePeerID - unreadPeerIDs = conversationStore.unreadDirectPeerIDs() } - private func refreshMessages() { - var nextMessagesByPeerID = conversationStore.directMessagesByPeerID() - var peerIDs = Set(nextMessagesByPeerID.keys) - peerIDs.formUnion(conversationStore.unreadDirectPeerIDs()) - if let selectedPeerID = conversationStore.selectedPrivatePeerID { - peerIDs.insert(selectedPeerID) - } + private func apply(_ change: ConversationChange) { + switch change { + case .appended(let id, _), + .updated(let id, _), + .statusChanged(let id, _, _), + .messageRemoved(let id, _), + .cleared(let id): + republishIfSelected(id) - for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil { - nextMessagesByPeerID[peerID] = [] - } + case .unreadChanged(let id, _): + guard isDirect(id) else { return } + refreshUnreadPeerIDs() - guard messagesByPeerID != nextMessagesByPeerID else { return } - messagesByPeerID = nextMessagesByPeerID + case .removed(let id): + guard isDirect(id) else { return } + refreshUnreadPeerIDs() + republishIfSelected(id) + + case .migrated(let source, let destination): + guard isDirect(source) || isDirect(destination) else { return } + refreshUnreadPeerIDs() + republishIfSelected(source) + republishIfSelected(destination) + } + } + + private func republishIfSelected(_ id: ConversationID) { + guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return } + objectWillChange.send() + } + + private func refreshUnreadPeerIDs() { + let next = conversations.unreadDirectRoutingPeerIDs() + guard unreadPeerIDs != next else { return } + unreadPeerIDs = next + } + + private func isDirect(_ id: ConversationID) -> Bool { + if case .direct = id { return true } + return false } } @@ -94,22 +118,22 @@ final class PrivateConversationModel: ObservableObject { @Published private(set) var selectedHeaderState: PrivateConversationHeaderState? private let chatViewModel: ChatViewModel - private let conversationStore: LegacyConversationStore + private let conversations: ConversationStore private let locationChannelsModel: LocationChannelsModel private let peerIdentityStore: PeerIdentityStore private var cancellables = Set() init( chatViewModel: ChatViewModel, - conversationStore: LegacyConversationStore, + conversations: ConversationStore, locationChannelsModel: LocationChannelsModel? = nil, peerIdentityStore: PeerIdentityStore? = nil ) { self.chatViewModel = chatViewModel - self.conversationStore = conversationStore + self.conversations = conversations self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore - let initialPeerID = conversationStore.selectedPrivatePeerID + let initialPeerID = conversations.selectedPrivatePeerID self.selectedPeerID = initialPeerID self.selectedHeaderState = initialPeerID.flatMap { peerID in makeHeaderState(for: peerID) @@ -154,7 +178,7 @@ final class PrivateConversationModel: ObservableObject { } private func bind() { - conversationStore.$selectedPrivatePeerID + conversations.$selectedPrivatePeerID .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.refreshSelectedConversation() @@ -198,7 +222,7 @@ final class PrivateConversationModel: ObservableObject { } private func refreshSelectedConversation() { - selectedPeerID = conversationStore.selectedPrivatePeerID + selectedPeerID = conversations.selectedPrivatePeerID selectedHeaderState = selectedPeerID.flatMap { peerID in makeHeaderState(for: peerID) } diff --git a/bitchat/App/PublicChatModel.swift b/bitchat/App/PublicChatModel.swift index 48159b71..fc9040ac 100644 --- a/bitchat/App/PublicChatModel.swift +++ b/bitchat/App/PublicChatModel.swift @@ -2,42 +2,76 @@ import BitFoundation import Combine import SwiftUI +/// Feature model for the active public (mesh/geohash) timeline. +/// +/// Observes ONE `Conversation` object in the single-writer +/// `ConversationStore` — the active channel's — so appends to background +/// conversations (other geohashes, private chats) never invalidate it. +/// `messages` reads the observed conversation's backing array directly; +/// there is no mirror copy. @MainActor final class PublicChatModel: ObservableObject { @Published private(set) var activeChannel: ChannelID - @Published private(set) var messages: [BitchatMessage] = [] - private let conversationStore: LegacyConversationStore + /// The active public conversation's timeline. + var messages: [BitchatMessage] { activeConversation.messages } + + private let conversations: ConversationStore + private var activeConversation: Conversation + private var activeConversationCancellable: AnyCancellable? private var cancellables = Set() - init(conversationStore: LegacyConversationStore) { - self.activeChannel = conversationStore.activeChannel - self.conversationStore = conversationStore + init(conversations: ConversationStore) { + let channel = conversations.activeChannel + self.conversations = conversations + self.activeChannel = channel + self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel)) + observeActiveConversation() bind() - refreshMessages() } private func bind() { - conversationStore.$activeChannel - .receive(on: DispatchQueue.main) + conversations.$activeChannel + .dropFirst() .sink { [weak self] channel in - self?.activeChannel = channel - self?.refreshMessages() + guard let self else { return } + self.activeChannel = channel + self.retargetActiveConversation(to: channel) } .store(in: &cancellables) - conversationStore.$messagesByConversation - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshMessages() + // The store replaces a conversation's object when it is removed + // (panic clear); retarget to the fresh instance so the observation + // never goes stale. + conversations.changes + .sink { [weak self] change in + guard let self, + case .removed(let id) = change, + id == self.activeConversation.id else { return } + self.retargetActiveConversation(to: self.activeChannel) } .store(in: &cancellables) } - private func refreshMessages() { - let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel)) - guard messages != nextMessages else { return } - messages = nextMessages + private func retargetActiveConversation(to channel: ChannelID) { + let conversation = conversations.conversation(for: ConversationID(channelID: channel)) + guard conversation !== activeConversation else { + // Same object (e.g. re-selected channel): keep the existing + // observation, but `messages` may still differ from what views + // last rendered, so republish. + objectWillChange.send() + return + } + objectWillChange.send() + activeConversation = conversation + observeActiveConversation() + } + + private func observeActiveConversation() { + activeConversationCancellable = activeConversation.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } } } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index f435d8e6..e9e81654 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -31,7 +31,6 @@ protocol CommandContextProvider: AnyObject { var activeChannel: ChannelID { get } var selectedPrivateChatPeer: PeerID? { get } var blockedUsers: Set { get } - var privateChats: [PeerID: [BitchatMessage]] { get } var idBridge: NostrIdentityBridge { get } // MARK: - Peer Lookup diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 62a905d2..11c50d60 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -14,9 +14,8 @@ import SwiftUI /// Manages private chat session policy (selection, read receipts, /// consolidation). Message storage lives in the single-writer /// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the -/// `privateChats` / `unreadMessages` properties below are read-only compat -/// views derived from it (migration step 2 — the manager shrinks to -/// read-receipt policy in step 5). +/// `privateChats` / `unreadMessages` properties below are read-only views +/// derived from it. final class PrivateChatManager: ObservableObject { @Published var selectedPeer: PeerID? = nil diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index e78adb6f..7a6e069d 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -51,9 +51,6 @@ enum TransportConfig { static let nostrInboundEventLogInterval: Int = 100 // UI thresholds - static let uiLateInsertThreshold: TimeInterval = 15.0 - // Geohash public chats are more sensitive to ordering; use a tighter threshold - static let uiLateInsertThresholdGeo: TimeInterval = 0.0 static let uiProcessedNostrEventsCap: Int = 2000 static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index f13f057d..e93b7eb9 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -13,7 +13,9 @@ import Foundation protocol ChatLifecycleContext: AnyObject { // MARK: Chat & receipt state var messages: [BitchatMessage] { get } - var privateChats: [PeerID: [BitchatMessage]] { get } + /// A single private chat's timeline (store-direct lookup on + /// `ChatViewModel`; no `privateChats` dictionary build). + func privateMessages(for peerID: PeerID) -> [BitchatMessage] var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get } /// Appends a private message via the single-writer store intent. @@ -73,7 +75,7 @@ protocol ChatLifecycleContext: AnyObject { } extension ChatViewModel: ChatLifecycleContext { - // `messages`, `privateChats`, `unreadPrivateMessages`, + // `messages`, `privateMessages(for:)`, `unreadPrivateMessages`, // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, // `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`, @@ -187,7 +189,7 @@ final class ChatLifecycleCoordinator { let recipientHex = context.nostrKeyMapping[peerID], case .location(let channel) = context.activeChannel, let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let messages = context.privateChats[peerID] ?? [] + let messages = context.privateMessages(for: peerID) for message in messages where message.senderPeerID == peerID && !message.isRelay { guard !context.sentReadReceipts.contains(message.id) else { continue } @@ -253,14 +255,12 @@ final class ChatLifecycleCoordinator { func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { var combined: [BitchatMessage] = [] - if let ephemeralMessages = context.privateChats[peerID] { - combined.append(contentsOf: ephemeralMessages) - } + combined.append(contentsOf: context.privateMessages(for: peerID)) if let peer = context.unifiedPeer(for: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] { - combined.append(contentsOf: stableMessages) + if noiseKeyHex != peerID { + combined.append(contentsOf: context.privateMessages(for: noiseKeyHex)) } } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index d604f8f8..ce51abe9 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -25,7 +25,6 @@ protocol ChatMediaTransferContext: AnyObject { func currentPublicSender() -> (name: String, peerID: PeerID) // MARK: Message state - var privateChats: [PeerID: [BitchatMessage]] { get } /// Appends a private message via the single-writer store intent. @discardableResult func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool @@ -52,7 +51,7 @@ protocol ChatMediaTransferContext: AnyObject { extension ChatViewModel: ChatMediaTransferContext { // `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`, // `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`, - // `currentPublicSender()`, `privateChats`, + // `currentPublicSender()`, // `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`, // `addSystemMessage(_:)`, `notifyUIChanged()`, // `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`, diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 2728afe3..645ac1ab 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -18,6 +18,9 @@ import Foundation protocol ChatPeerIdentityContext: AnyObject { // MARK: Conversation state var privateChats: [PeerID: [BitchatMessage]] { get } + /// A single private chat's timeline. Witnessed by the store-direct + /// lookup on `ChatViewModel` (no `privateChats` dictionary build). + func privateMessages(for peerID: PeerID) -> [BitchatMessage] var unreadPrivateMessages: Set { get } /// Clears the peer's unread flag (single-writer store intent). func markPrivateChatRead(_ peerID: PeerID) @@ -229,7 +232,7 @@ final class ChatPeerIdentityCoordinator { @MainActor func openMostRelevantPrivateChat() { let unreadSorted = context.unreadPrivateMessages - .map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } + .map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) } .sorted { $0.1 > $1.1 } if let target = unreadSorted.first?.0 { startPrivateChat(with: target) @@ -563,7 +566,7 @@ private extension ChatPeerIdentityCoordinator { func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { if context.selectedPrivateChatPeer == oldPeerID { SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) - } else if context.privateChats[oldPeerID] != nil { + } else if !context.privateMessages(for: oldPeerID).isEmpty { SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) } @@ -613,7 +616,7 @@ private extension ChatPeerIdentityCoordinator { } let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey) - let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender + let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender let plan = ChatFavoriteTogglePolicy.plan( currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), fallbackNickname: fallbackNickname, @@ -642,3 +645,11 @@ private extension ChatPeerIdentityCoordinator { } } } + +/// Default for conforming test contexts that model chats as a dictionary; +/// `ChatViewModel` overrides with a store-direct lookup. +extension ChatPeerIdentityContext { + func privateMessages(for peerID: PeerID) -> [BitchatMessage] { + privateChats[peerID] ?? [] + } +} diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 167401a6..593aaf83 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -13,7 +13,9 @@ import Foundation protocol ChatPeerListContext: AnyObject { // MARK: Connection & chat state var isConnected: Bool { get set } - var privateChats: [PeerID: [BitchatMessage]] { get } + /// A single private chat's timeline (store-direct lookup on + /// `ChatViewModel`; no `privateChats` dictionary build). + func privateMessages(for peerID: PeerID) -> [BitchatMessage] var unreadPrivateMessages: Set { get } /// Clears the peer's unread flag (single-writer store intent). func markPrivateChatRead(_ peerID: PeerID) @@ -37,7 +39,7 @@ protocol ChatPeerListContext: AnyObject { } extension ChatViewModel: ChatPeerListContext { - // `isConnected`, `privateChats`, `unreadPrivateMessages`, + // `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`, // `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`, // `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`, // `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and @@ -148,11 +150,11 @@ private extension ChatPeerListCoordinator { var idsToRemove: [PeerID] = [] for staleID in staleIDs { - if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty { + if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty { continue } - if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty { + if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty { continue } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 401536de..f1d9e1f7 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -15,6 +15,9 @@ import Foundation protocol ChatPrivateConversationContext: AnyObject { // MARK: Conversation state var privateChats: [PeerID: [BitchatMessage]] { get } + /// A single private chat's timeline. Witnessed by the store-direct + /// lookup on `ChatViewModel` (no `privateChats` dictionary build). + func privateMessages(for peerID: PeerID) -> [BitchatMessage] var sentReadReceipts: Set { get } var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get } @@ -588,8 +591,8 @@ final class ChatPrivateConversationCoordinator { if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) { let stableKeyHex = PeerID(hexData: peerNoiseKey) + let nostrMessages = context.privateMessages(for: stableKeyHex) if stableKeyHex != peerID, - let nostrMessages = context.privateChats[stableKeyHex], !nostrMessages.isEmpty { // Store migration dedups by ID, keeps timestamp order, and // removes the stable-key chat. @@ -767,7 +770,7 @@ final class ChatPrivateConversationCoordinator { func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { let currentFingerprint = context.getFingerprint(for: peerID) - if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true { + if context.privateMessages(for: peerID).isEmpty { // Chats migrated wholesale go through the store's // `migrateConversation` intent; partially-migrated chats keep // their non-recent tail, so the recent messages are copied in @@ -876,3 +879,11 @@ final class ChatPrivateConversationCoordinator { return false } } + +/// Default for conforming test contexts that model chats as a dictionary; +/// `ChatViewModel` overrides with a store-direct lookup. +extension ChatPrivateConversationContext { + func privateMessages(for peerID: PeerID) -> [BitchatMessage] { + privateChats[peerID] ?? [] + } +} diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 4a6dab4a..d6777a71 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -51,9 +51,8 @@ protocol ChatPublicConversationContext: AnyObject { func queueGeohashSystemMessage(_ content: String) // MARK: Private chats (block cleanup & message removal) - var privateChats: [PeerID: [BitchatMessage]] { get } /// Removes the peer's chat entirely, including unread state - /// (single-writer store intent). + /// (single-writer store intent; no-op for unknown peers). func removePrivateChat(_ peerID: PeerID) /// Removes a message by ID from every private chat containing it, /// dropping chats that become empty. Returns the removed message. @@ -102,7 +101,7 @@ protocol ChatPublicConversationContext: AnyObject { } extension ChatViewModel: ChatPublicConversationContext { - // `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`, + // `unreadPrivateMessages`, `nostrKeyMapping`, // `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`, // `myPeerID`, `isTeleported`, `notifyUIChanged()`, // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, @@ -218,10 +217,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.removePublicMessages(fromGeohash: gh, where: predicate) } - let conversationPeerID = PeerID(nostr_: hex) - if context.privateChats[conversationPeerID] != nil { - context.removePrivateChat(conversationPeerID) - } + // The store intent no-ops when no such chat exists. + context.removePrivateChat(PeerID(nostr_: hex)) context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 33e46b93..f803c89b 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -15,7 +15,9 @@ protocol ChatTransportEventContext: AnyObject { var isConnected: Bool { get set } var nickname: String { get } var myPeerID: PeerID { get } - var privateChats: [PeerID: [BitchatMessage]] { get } + /// A single private chat's timeline (store-direct lookup on + /// `ChatViewModel`; no `privateChats` dictionary build). + func privateMessages(for peerID: PeerID) -> [BitchatMessage] var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get set } /// Appends a private message via the single-writer store intent; @@ -70,7 +72,7 @@ protocol ChatTransportEventContext: AnyObject { } extension ChatViewModel: ChatTransportEventContext { - // `isConnected`, `nickname`, `myPeerID`, `privateChats`, + // `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`, // `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`, // the inbound message handlers, `isPeerBlocked(_:)`, // `parseMentions(from:)`, `resolveNickname(for:)`, @@ -233,12 +235,10 @@ final class ChatTransportEventCoordinator { ) } - if let messages = context.privateChats[peerID] { - let receiptIDs = messages - .filter { $0.senderPeerID == peerID } - .map(\.id) - context.unmarkReadReceiptsSent(receiptIDs) - } + let receiptIDs = context.privateMessages(for: peerID) + .filter { $0.senderPeerID == peerID } + .map(\.id) + context.unmarkReadReceiptsSent(receiptIDs) context.notifyUIChanged() } @@ -261,8 +261,9 @@ private extension ChatTransportEventCoordinator { ) { let hadUnread = context.unreadPrivateMessages.contains(shortPeerID) - if let messages = context.privateChats[shortPeerID] { - for message in messages { + let shortPeerMessages = context.privateMessages(for: shortPeerID) + if !shortPeerMessages.isEmpty { + for message in shortPeerMessages { // Rewrite senderPeerID to the stable key so read receipts // keep working; store append dedups by ID and keeps order. let migrated = BitchatMessage( diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8be04fc0..2a15d59d 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -120,11 +120,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Published Properties /// Read-only derived view of the ACTIVE public channel's conversation in - /// the single-writer `ConversationStore` (migration step 3 shim; views - /// observe `Conversation` objects directly in step 5). Hot for rendering, - /// so the array is cached and invalidated from the store's `changes` - /// subject (filtered to the active conversation) and on channel switches. - /// `objectWillChange` fires on every store change via the sink in `init`. + /// the single-writer `ConversationStore`. SwiftUI renders through + /// `PublicChatModel` (which observes the `Conversation` object directly); + /// this view serves the coordinators/commands that need "the visible + /// timeline" plus tests. Hot enough that the array is cached and + /// invalidated from the store's `changes` subject (filtered to the + /// active conversation) and on channel switches. `objectWillChange` + /// fires on every store change via the sink in `init`. @MainActor var messages: [BitchatMessage] { if let cached = visibleMessagesCache { return cached } @@ -180,13 +182,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var connectedPeers: Set { unifiedPeerService.connectedPeerIDs } @Published var allPeers: [BitchatPeer] = [] - /// Read-only compat view of all direct conversations in the new - /// `ConversationStore`, keyed by routing peer ID (migration step 2 shim; - /// views/feature models observe `Conversation` objects directly in - /// step 5). All mutations go through the private-chat intent ops below. - /// Rebuilt per access — O(#conversations) thanks to COW message arrays; - /// measured equal to a change-invalidated cache on - /// `pipeline.privateIngest`, so the simpler form wins. + /// Read-only derived view of all direct conversations in the + /// `ConversationStore`, keyed by routing peer ID. Serves the coordinator + /// reads that genuinely need the whole dictionary (migration scans, + /// unread resolution); simple per-peer reads go through + /// `privateMessages(for:)` instead. All mutations go through the + /// private-chat intent ops below. Rebuilt per access — + /// O(#conversations) thanks to COW message arrays; measured equal to a + /// change-invalidated cache on `pipeline.privateIngest`, so the simpler + /// form wins. @MainActor var privateChats: [PeerID: [BitchatMessage]] { conversations.directMessagesByRoutingPeerID() @@ -203,9 +207,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele synchronizeConversationSelectionStore() } } - /// Read-only compat view of the store's unread direct conversations - /// (migration step 2 shim). Mutate via `markPrivateChatUnread(_:)` / - /// `markPrivateChatRead(_:)`. + /// Read-only derived view of the store's unread direct conversations. + /// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`. @MainActor var unreadPrivateMessages: Set { conversations.unreadDirectRoutingPeerIDs() @@ -291,15 +294,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol - let conversationStore: LegacyConversationStore - /// Single source of truth for conversation message state + /// Single source of truth for conversation message state and selection /// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed - /// through, mirroring the legacy store's wiring. + /// through. let conversations: ConversationStore - /// Keeps `LegacyConversationStore` fed from `conversations` while feature - /// models still read Legacy (DELETE IN STEP 5). - private var legacyStoreBridge: LegacyConversationStoreBridge? - let identityResolver: IdentityResolver let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore let locationManager: LocationChannelManager @@ -310,12 +308,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) var activeChannel: ChannelID { - get { conversationStore.activeChannel } + get { conversations.activeChannel } set { - guard conversationStore.activeChannel != newValue else { return } - conversationStore.setActiveChannel(newValue) + guard conversations.activeChannel != newValue else { return } + conversations.setActiveChannel(newValue) visibleMessagesCache = nil - synchronizeConversationSelectionStore() objectWillChange.send() } } @@ -550,7 +547,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // The sole mutation paths for private (direct) message state. Each op // forwards to the single-writer `ConversationStore` // (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` / - // `unreadPrivateMessages` shims above are derived from the same store. + // `unreadPrivateMessages` views above are derived from the same store. /// Appends a private message in timestamp order. Returns `false` when a /// message with the same ID is already in that chat (O(1) dedup via the @@ -610,6 +607,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID)) } + /// A single private chat's timeline, read straight from the store — + /// an O(1) lookup that skips the `privateChats` dictionary build. The + /// context protocols' simple per-peer reads dispatch here. + @MainActor + func privateMessages(for peerID: PeerID) -> [BitchatMessage] { + conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] + } + /// `true` when any private chat contains a message with `messageID` /// (O(1) per conversation via the store's ID indexes). @MainActor @@ -724,23 +729,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - conversationStore: LegacyConversationStore? = nil, conversations: ConversationStore? = nil, - identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let conversationStore = conversationStore ?? LegacyConversationStore() - let identityResolver = identityResolver ?? IdentityResolver() self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), - conversationStore: conversationStore, conversations: conversations, - identityResolver: identityResolver, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), locationManager: locationManager @@ -755,16 +754,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, transport: Transport, - conversationStore: LegacyConversationStore? = nil, conversations: ConversationStore? = nil, - identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let conversationStore = conversationStore ?? LegacyConversationStore() let conversations = conversations ?? ConversationStore() - let identityResolver = identityResolver ?? IdentityResolver() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() let services = ChatViewModelServiceBundle( @@ -777,9 +772,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.keychain = keychain self.idBridge = idBridge self.identityManager = identityManager - self.conversationStore = conversationStore self.conversations = conversations - self.identityResolver = identityResolver self.peerIdentityStore = peerIdentityStore self.locationPresenceStore = locationPresenceStore self.locationManager = locationManager @@ -793,21 +786,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.publicMessagePipeline = services.publicMessagePipeline self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts() - // Keep the legacy store fed from the new store until the feature - // models cut over (migration step 5). - self.legacyStoreBridge = LegacyConversationStoreBridge( - store: conversations, - legacyStore: conversationStore, - identityResolver: identityResolver - ) - // Republish on every store change so SwiftUI observers of the // view model refresh. This replaces the UI-update role of the old // `PrivateChatManager.@Published` dictionaries and the old - // `@Published var messages` (their debounced Legacy synchronization - // sinks are gone; the bridge above feeds Legacy instead). Changes - // touching the ACTIVE public conversation also invalidate the - // derived `messages` cache before observers re-read it. + // `@Published var messages`. Changes touching the ACTIVE public + // conversation also invalidate the derived `messages` cache before + // observers re-read it. conversations.changes .sink { [weak self] change in guard let self else { return } @@ -819,7 +803,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele .store(in: &cancellables) ChatViewModelBootstrapper(viewModel: self).configure() - initializeConversationStore() + synchronizeConversationSelectionStore() } // MARK: - Deinitialization @@ -1156,7 +1140,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele bleService.resetIdentityForPanic(currentNickname: nickname) } - initializeConversationStore() + synchronizeConversationSelectionStore() // No need to force UserDefaults synchronization @@ -1278,28 +1262,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Message Handling - @MainActor - func initializeConversationStore() { - conversationStore.setActiveChannel(activeChannel) - synchronizeConversationSelectionStore() - } - - /// Full Legacy-store resynchronization from the new `ConversationStore`. - /// Needed when `IdentityResolver` learns new peer associations, which can - /// re-key a direct conversation's canonical handle in Legacy - /// (DELETE IN STEP 5 with the bridge). - @MainActor - func resynchronizeLegacyPrivateConversations() { - legacyStoreBridge?.resynchronizeAll() - } - + /// Pushes the private-chat manager's selection into the store, which + /// derives `selectedConversationID` from it and the active channel. @MainActor func synchronizeConversationSelectionStore() { - conversationStore.setSelectedPeerID( - privateChatManager.selectedPeer, - activeChannel: activeChannel, - identityResolver: identityResolver - ) + conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer) } /// Invalidates the derived `messages` cache and notifies observers. diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 9be99cfe..3ec15fa8 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -90,10 +90,9 @@ private extension ChatViewModelBootstrapper { } .store(in: &viewModel.cancellables) - // Private message state now flows: store intent → - // `ConversationStore.changes` → `LegacyConversationStoreBridge` (and - // the ChatViewModel shim-cache sink), so the old `$privateChats` / - // `$unreadMessages` debounced synchronization sinks are gone. + // Private message state flows through the single-writer + // `ConversationStore` intents and its `changes` subject; only the + // selection still originates in `PrivateChatManager`. viewModel.privateChatManager.$selectedPeer .receive(on: DispatchQueue.main) .sink { [weak viewModel] _ in @@ -159,7 +158,6 @@ private extension ChatViewModelBootstrapper { guard let viewModel else { return } viewModel.allPeers = peers - viewModel.identityResolver.register(peers: peers) var uniquePeers: [PeerID: BitchatPeer] = [:] for peer in peers { @@ -178,9 +176,6 @@ private extension ChatViewModelBootstrapper { viewModel.updatePrivateChatPeerIfNeeded() } - // Peer registrations can change a conversation's - // canonical handle in the legacy store; re-key it. - viewModel.resynchronizeLegacyPrivateConversations() viewModel.synchronizeConversationSelectionStore() } } diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index 3ccc8f1e..95753611 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -81,7 +81,7 @@ struct CommandSuggestionsView: View { ) let privateConversationModel = PrivateConversationModel( chatViewModel: viewModel, - conversationStore: viewModel.conversationStore + conversations: viewModel.conversations ) let locationChannelsModel = LocationChannelsModel() diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 6b1be23a..3e01bfd8 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -76,12 +76,12 @@ struct TextMessageView: View { ) let privateConversationModel = PrivateConversationModel( chatViewModel: viewModel, - conversationStore: viewModel.conversationStore + conversations: viewModel.conversations ) let conversationUIModel = ConversationUIModel( chatViewModel: viewModel, privateConversationModel: privateConversationModel, - conversationStore: viewModel.conversationStore + conversations: viewModel.conversations ) Group { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 095e864d..1308c637 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -1,4 +1,5 @@ import BitFoundation +import Combine import Foundation import Testing @testable import bitchat @@ -45,6 +46,27 @@ private func makeArchitectureSnapshot( ) } +@MainActor +private func makeArchitectureMessage( + id: String, + timestamp: TimeInterval = 0, + content: String? = nil, + isPrivate: Bool = false, + senderPeerID: PeerID = PeerID(str: "peer-a") +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "alice", + content: content ?? "message \(id)", + timestamp: Date(timeIntervalSince1970: timestamp), + isRelay: false, + originalSender: nil, + isPrivate: isPrivate, + recipientNickname: isPrivate ? "builder" : nil, + senderPeerID: senderPeerID + ) +} + @MainActor private func waitUntil( timeoutNanoseconds: UInt64 = 3_000_000_000, @@ -127,251 +149,119 @@ struct AppArchitectureTests { @Test("PeerHandle equality and hashing use the canonical identity only") func peerHandleEqualityUsesCanonicalIdentity() { - let first = PeerHandle( - id: "noise:abc123", - routingPeerID: PeerID(str: "peer-a"), - displayName: "alice", - noisePublicKeyHex: "abc123", - nostrPublicKey: nil - ) - let second = PeerHandle( - id: "noise:abc123", - routingPeerID: PeerID(str: "peer-b"), - displayName: "alice-renamed", - noisePublicKeyHex: nil, - nostrPublicKey: "npub123" - ) + let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a")) + let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b")) #expect(first == second) #expect(Set([first, second]).count == 1) } - @Test("LegacyConversationStore normalizes timeline ordering and duplicates") + @Test("ConversationStore orders timelines and replaces duplicates by message ID") @MainActor - func conversationStoreNormalizesMessages() { - let store = LegacyConversationStore() - let older = BitchatMessage( - id: "m1", - sender: "alice", - content: "first", - timestamp: Date(timeIntervalSince1970: 1), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: PeerID(str: "peer-a") - ) - let newer = BitchatMessage( - id: "m2", - sender: "alice", - content: "second", - timestamp: Date(timeIntervalSince1970: 2), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: PeerID(str: "peer-a") - ) - let replacement = BitchatMessage( - id: "m2", - sender: "alice", - content: "second-updated", - timestamp: Date(timeIntervalSince1970: 2), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: PeerID(str: "peer-a") - ) + func conversationStoreOrdersAndDedupsMessages() { + let store = ConversationStore() + let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first") + let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second") + let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated") - store.replaceMessages([newer, older, replacement], for: ConversationID.mesh) + store.append(newer, to: .mesh) + store.append(older, to: .mesh) + store.upsertByID(replacement, in: .mesh) - let messages = store.messages(for: ConversationID.mesh) + let messages = store.conversation(for: .mesh).messages #expect(messages.map(\.id) == ["m1", "m2"]) #expect(messages.last?.content == "second-updated") } - @Test("LegacyConversationStore tracks unread direct conversations with canonical IDs") + @Test("ConversationStore tracks unread direct conversations by routing peer ID") @MainActor func conversationStoreTracksUnreadDirectConversations() { - let store = LegacyConversationStore() - let resolver = IdentityResolver() + let store = ConversationStore() let peerID = PeerID(str: "peer-1") - let message = BitchatMessage( - id: "dm-1", - sender: "alice", - content: "hello", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "bob", - senderPeerID: peerID - ) + let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID) - store.synchronizePrivateChats( - [peerID: [message]], - unreadPeerIDs: Set([peerID]), - identityResolver: resolver - ) + store.append(message, to: .directPeer(peerID)) + store.markUnread(.directPeer(peerID)) - let conversationID = ConversationID.direct( - resolver.canonicalHandle(for: peerID, displayName: "alice") - ) + #expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"]) + #expect(store.unreadDirectRoutingPeerIDs() == Set([peerID])) + #expect(store.conversation(for: .directPeer(peerID)).isUnread) - #expect(store.messages(for: conversationID).map(\.id) == ["dm-1"]) - #expect(store.unreadConversations.contains(conversationID)) - - store.markRead(conversationID) - #expect(!store.unreadConversations.contains(conversationID)) + store.markRead(.directPeer(peerID)) + #expect(store.unreadDirectRoutingPeerIDs().isEmpty) + #expect(!store.conversation(for: .directPeer(peerID)).isUnread) } - @Test("LegacyConversationStore tracks the selected app conversation context") + @Test("ConversationStore derives the selected conversation from channel and private peer") @MainActor func conversationStoreTracksSelectedConversationContext() { - let store = LegacyConversationStore() - let resolver = IdentityResolver() - let noiseKey = Data((0..<32).map(UInt8.init)) - let shortPeerID = PeerID(str: "0011223344556677") + let store = ConversationStore() + let peerID = PeerID(str: "0011223344556677") let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) - let peer = BitchatPeer( - peerID: shortPeerID, - noisePublicKey: noiseKey, - nickname: "alice", - isConnected: true, - isReachable: true - ) - resolver.register(peers: [peer]) - store.synchronizeSelection( - activeChannel: geohashChannel, - selectedPeerID: shortPeerID, - identityResolver: resolver - ) - - let expectedConversationID = ConversationID.direct( - resolver.canonicalHandle(for: shortPeerID, displayName: "alice") - ) + store.setActiveChannel(geohashChannel) + store.setSelectedPrivatePeer(peerID) #expect(store.activeChannel == geohashChannel) - #expect(store.selectedPrivatePeerID == shortPeerID) - #expect(store.selectedConversationID == expectedConversationID) + #expect(store.selectedPrivatePeerID == peerID) + // The open private chat wins the derived selection. + #expect(store.selectedConversationID == ConversationID.directPeer(peerID)) - store.synchronizeSelection( - activeChannel: ChannelID.mesh, - selectedPeerID: nil, - identityResolver: resolver - ) + store.setSelectedPrivatePeer(nil) + // Selection falls back to the active public channel. + #expect(store.selectedConversationID == ConversationID(channelID: geohashChannel)) + store.setActiveChannel(.mesh) #expect(store.activeChannel == ChannelID.mesh) #expect(store.selectedPrivatePeerID == nil) #expect(store.selectedConversationID == ConversationID.mesh) } - @Test("LegacyConversationStore exposes direct conversations by the latest routing peer ID") + @Test("ConversationStore re-keys a direct conversation via the migrate intent") @MainActor - func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() { - let store = LegacyConversationStore() - let resolver = IdentityResolver() + func conversationStoreMigratesDirectConversationsBetweenPeerIDs() { + let store = ConversationStore() let noiseKey = Data((0..<32).map(UInt8.init)) let shortPeerID = PeerID(str: "0011223344556677") let fullPeerID = PeerID(hexData: noiseKey) - let firstMessage = BitchatMessage( - id: "dm-1", - sender: "alice", - content: "short id", - timestamp: Date(timeIntervalSince1970: 1), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "builder", - senderPeerID: shortPeerID - ) - let secondMessage = BitchatMessage( - id: "dm-2", - sender: "alice", - content: "full id", - timestamp: Date(timeIntervalSince1970: 2), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "builder", - senderPeerID: fullPeerID - ) - resolver.register( - peer: BitchatPeer( - peerID: shortPeerID, - noisePublicKey: noiseKey, - nickname: "alice", - isConnected: true, - isReachable: true - ) - ) - store.synchronizePrivateChats( - [shortPeerID: [firstMessage]], - unreadPeerIDs: Set([shortPeerID]), - identityResolver: resolver + store.append( + makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID), + to: .directPeer(shortPeerID) ) + store.markUnread(.directPeer(shortPeerID)) + store.setSelectedPrivatePeer(shortPeerID) - resolver.register( - peer: BitchatPeer( - peerID: fullPeerID, - noisePublicKey: noiseKey, - nickname: "alice", - isConnected: true, - isReachable: true - ) - ) - store.synchronizePrivateChats( - [fullPeerID: [secondMessage]], - unreadPeerIDs: Set([fullPeerID]), - identityResolver: resolver - ) + store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID)) - #expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID])) - #expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"]) - #expect(store.unreadDirectPeerIDs() == Set([fullPeerID])) + // Raw keying: the old peer's conversation is gone, the new peer's + // conversation holds the timeline, unread and selection carried over. + #expect(store.conversationsByID[.directPeer(shortPeerID)] == nil) + #expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID])) + #expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"]) + #expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID])) + #expect(store.selectedPrivatePeerID == fullPeerID) + #expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID)) } - @Test("PrivateInboxModel mirrors direct message state from LegacyConversationStore") + @Test("PrivateInboxModel reads direct message state from the ConversationStore") @MainActor - func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async { - let store = LegacyConversationStore() - let resolver = IdentityResolver() - let inboxModel = PrivateInboxModel(conversationStore: store) + func privateInboxModelReadsDirectMessageStateFromConversationStore() { + let store = ConversationStore() + let inboxModel = PrivateInboxModel(conversations: store) let messagePeerID = PeerID(str: "peer-1") let unreadOnlyPeerID = PeerID(str: "peer-2") let selectedOnlyPeerID = PeerID(str: "peer-3") - let message = BitchatMessage( - id: "dm-1", - sender: "alice", - content: "hello", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: "builder", - senderPeerID: messagePeerID - ) - store.synchronizePrivateChats( - [messagePeerID: [message]], - unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]), - identityResolver: resolver - ) - store.synchronizeSelection( - activeChannel: ChannelID.mesh, - selectedPeerID: selectedOnlyPeerID, - identityResolver: resolver + store.append( + makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID), + to: .directPeer(messagePeerID) ) + store.markUnread(.directPeer(messagePeerID)) + store.markUnread(.directPeer(unreadOnlyPeerID)) + store.setSelectedPrivatePeer(selectedOnlyPeerID) - await waitUntil { - inboxModel.selectedPeerID == selectedOnlyPeerID && - inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) && - Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID]) - } - + // Reads are synchronous against the single-writer store. #expect(inboxModel.selectedPeerID == selectedOnlyPeerID) #expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID])) #expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"]) @@ -379,13 +269,74 @@ struct AppArchitectureTests { #expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty) } + @Test("PrivateInboxModel republishes only for the selected conversation") + @MainActor + func privateInboxModelIsolatesBackgroundConversations() { + let store = ConversationStore() + let inboxModel = PrivateInboxModel(conversations: store) + let selectedPeerID = PeerID(str: "peer-selected") + let backgroundPeerID = PeerID(str: "peer-background") + store.setSelectedPrivatePeer(selectedPeerID) + + var emissions = 0 + let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 } + defer { cancellable.cancel() } + + let baseline = emissions + store.append( + makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID), + to: .directPeer(backgroundPeerID) + ) + // An append to a background chat does not republish the model. + #expect(emissions == baseline) + + store.append( + makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID), + to: .directPeer(selectedPeerID) + ) + #expect(emissions == baseline + 1) + #expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"]) + } + + @Test("PublicChatModel ignores appends to background conversations") + @MainActor + func publicChatModelIsolatesBackgroundConversations() { + let store = ConversationStore() + store.setActiveChannel(.mesh) + let model = PublicChatModel(conversations: store) + + var emissions = 0 + let cancellable = model.objectWillChange.sink { _ in emissions += 1 } + defer { cancellable.cancel() } + + store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh) + let afterActiveAppend = emissions + #expect(afterActiveAppend >= 1) + #expect(model.messages.map(\.id) == ["mesh-1"]) + + // Appends to a background geohash channel and to a private chat do + // not invalidate the observer of the active conversation. + store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd")) + store.append( + makeArchitectureMessage(id: "dm-1", isPrivate: true), + to: .directPeer(PeerID(str: "peer-1")) + ) + #expect(emissions == afterActiveAppend) + #expect(model.messages.map(\.id) == ["mesh-1"]) + + // Switching the channel retargets the observation. + store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd"))) + #expect(model.messages.map(\.id) == ["geo-1"]) + store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd")) + #expect(model.messages.map(\.id) == ["geo-1", "geo-2"]) + } + @Test("AppChromeModel mirrors nickname and unread state through focused models") @MainActor func appChromeModelMirrorsNicknameAndUnreadState() async { let viewModel = makeArchitectureViewModel() - let conversationStore = LegacyConversationStore() - let resolver = IdentityResolver() - let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + let conversations = ConversationStore() + let privateInboxModel = PrivateInboxModel(conversations: conversations) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) chromeModel.setNickname("builder") @@ -398,11 +349,7 @@ struct AppArchitectureTests { #expect(!chromeModel.hasUnreadPrivateMessages) let peerID = PeerID(str: "peer-1") - conversationStore.synchronizePrivateChats( - [:], - unreadPeerIDs: Set([peerID]), - identityResolver: resolver - ) + conversations.markUnread(.directPeer(peerID)) await waitUntil { chromeModel.hasUnreadPrivateMessages } @@ -414,8 +361,7 @@ struct AppArchitectureTests { @MainActor func appChromeModelOwnsPresentationState() { let viewModel = makeArchitectureViewModel() - let conversationStore = LegacyConversationStore() - let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) let peerID = PeerID(str: "peer-2") @@ -441,11 +387,10 @@ struct AppArchitectureTests { Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") return } - let conversationStore = viewModel.conversationStore let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) let conversationModel = PrivateConversationModel( chatViewModel: viewModel, - conversationStore: conversationStore, + conversations: viewModel.conversations, locationChannelsModel: locationChannelsModel ) @@ -493,18 +438,17 @@ struct AppArchitectureTests { return } - let conversationStore = viewModel.conversationStore locationManager.select(.mesh) let locationChannelsModel = LocationChannelsModel(manager: locationManager) let privateConversationModel = PrivateConversationModel( chatViewModel: viewModel, - conversationStore: conversationStore, + conversations: viewModel.conversations, locationChannelsModel: locationChannelsModel ) let uiModel = ConversationUIModel( chatViewModel: viewModel, privateConversationModel: privateConversationModel, - conversationStore: conversationStore + conversations: viewModel.conversations ) let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) defer { @@ -558,11 +502,10 @@ struct AppArchitectureTests { let peerID = PeerID(str: "0011223344556677") let fingerprint = "verified-fingerprint" - let conversationStore = viewModel.conversationStore let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) let privateConversationModel = PrivateConversationModel( chatViewModel: viewModel, - conversationStore: conversationStore, + conversations: viewModel.conversations, locationChannelsModel: locationChannelsModel ) let verificationModel = VerificationModel( @@ -664,7 +607,7 @@ struct AppArchitectureTests { let peerListModel = PeerListModel( chatViewModel: viewModel, - conversationStore: viewModel.conversationStore, + conversations: viewModel.conversations, locationChannelsModel: locationChannelsModel ) diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 8e5d2a88..be13d01e 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -29,6 +29,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { // Chat & receipt state var messages: [BitchatMessage] = [] var privateChats: [PeerID: [BitchatMessage]] = [:] + + func privateMessages(for peerID: PeerID) -> [BitchatMessage] { + privateChats[peerID] ?? [] + } var unreadPrivateMessages: Set = [] var selectedPrivateChatPeer: PeerID? var sentReadReceipts: Set = [] diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 8a81611c..e4abb1b3 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -27,6 +27,10 @@ private final class MockChatPeerListContext: ChatPeerListContext { // Connection & chat state var isConnected = false var privateChats: [PeerID: [BitchatMessage]] = [:] + + func privateMessages(for peerID: PeerID) -> [BitchatMessage] { + privateChats[peerID] ?? [] + } var unreadPrivateMessages: Set = [] var hasTrackedPrivateChatSelection = false private(set) var updatePrivateChatPeerIfNeededCount = 0 diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index e9de3901..d7e6c863 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -28,6 +28,10 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { var nickname = "me" var myPeerID = PeerID(str: "0011223344556677") var privateChats: [PeerID: [BitchatMessage]] = [:] + + func privateMessages(for peerID: PeerID) -> [BitchatMessage] { + privateChats[peerID] ?? [] + } var unreadPrivateMessages: Set = [] var selectedPrivateChatPeer: PeerID? private(set) var unmarkedReadReceiptBatches: [[String]] = [] diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index ecba96d0..8045feae 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -553,10 +553,7 @@ struct ChatViewModelNoisePayloadTests { }, timeout: TestConstants.defaultTimeout) let conversationStoreUpdated = await TestHelpers.waitUntil({ - let messages = viewModel.conversationStore.directMessages( - for: peerID, - identityResolver: viewModel.identityResolver - ) + let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] guard let status = messages.first?.deliveryStatus else { return false } if case .read = status { return true diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index 3cd0f225..efb03a89 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -41,10 +41,7 @@ private func makeMessage( private func makeDirectConversationID(_ suffix: String) -> ConversationID { .direct(PeerHandle( id: "noise:\(suffix)", - routingPeerID: PeerID(str: "peer-\(suffix)"), - displayName: "peer \(suffix)", - noisePublicKeyHex: suffix, - nostrPublicKey: nil + routingPeerID: PeerID(str: "peer-\(suffix)") )) } diff --git a/bitchatTests/LegacyConversationStoreBridgeTests.swift b/bitchatTests/LegacyConversationStoreBridgeTests.swift deleted file mode 100644 index 98d4b32d..00000000 --- a/bitchatTests/LegacyConversationStoreBridgeTests.swift +++ /dev/null @@ -1,246 +0,0 @@ -// -// 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 -// - -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"]) - } - - // MARK: - Public mirroring (migration step 3) - - @Test("public messages mirror into Legacy via the coalesced flush") - @MainActor - func publicMessagesMirrorIntoLegacy() async { - let (viewModel, store, legacy, _) = makeBridgedFixture() - - viewModel.appendPublicMessage( - BitchatMessage( - id: "bridge-pub-1", - sender: "alice", - content: "hello mesh", - timestamp: Date(), - isRelay: false - ), - to: .mesh - ) - viewModel.appendGeohashMessageIfAbsent( - BitchatMessage( - id: "bridge-geo-1", - sender: "bob#abcd", - content: "hello geohash", - timestamp: Date(), - isRelay: false - ), - toGeohash: "U4PRUYD" - ) - - // The new store is synchronously authoritative (geohash keys are - // normalized to lowercase). - #expect(store.conversation(for: .mesh).messages.map(\.id) == ["bridge-pub-1"]) - #expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["bridge-geo-1"]) - - // Legacy catches up within one coalesced flush. - let mirrored = await TestHelpers.waitUntil( - { - legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-1"] - && legacy.messages(for: .geohash("u4pruyd")).map(\.id) == ["bridge-geo-1"] - }, - timeout: 1.0 - ) - #expect(mirrored) - } - - @Test("removing a public conversation empties its Legacy mirror immediately") - @MainActor - func publicConversationRemovalClearsLegacy() async { - let (viewModel, store, legacy, _) = makeBridgedFixture() - viewModel.appendPublicMessage( - BitchatMessage( - id: "bridge-pub-2", - sender: "alice", - content: "soon gone", - timestamp: Date(), - isRelay: false - ), - to: .mesh - ) - let mirrored = await TestHelpers.waitUntil( - { legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-2"] }, - timeout: 1.0 - ) - #expect(mirrored) - - // Panic-style removal: Legacy must never show stale public messages. - store.removeConversation(.mesh) - #expect(legacy.messages(for: .mesh).isEmpty) - } -} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 88189b27..f8954db5 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -315,14 +315,13 @@ final class PerformanceBaselineTests: XCTestCase { // MARK: - 6a. End-to-end private ingest pipeline (current architecture) /// Baseline for the full private-message ingest cycle through a real - /// `ChatViewModel`: `handlePrivateMessage` → `privateChats` passthrough → - /// `PrivateChatManager`'s `@Published` dict → bootstrapper Combine sink → - /// `Task.yield`-debounced `LegacyConversationStore.synchronizePrivateChats` - /// (full-dict replace) → `PrivateInboxModel.refreshMessages` (full - /// rebuild). Measures wall time from first ingest until the store AND the - /// feature model both reflect every message. The peer is not mesh-active - /// and no chat is selected, so notification/read-receipt side paths stay - /// cold (notifications are no-ops under test anyway). + /// `ChatViewModel`: `handlePrivateMessage` → `ConversationStore.append` + /// intent (ordered insert + ID-index dedup) → per-conversation publish + + /// `changes` emission → `PrivateInboxModel` (direct store reads). + /// Measures wall time from first ingest until the store AND the feature + /// model both reflect every message. The peer is not mesh-active and no + /// chat is selected, so notification/read-receipt side paths stay cold + /// (notifications are no-ops under test anyway). func testPipelinePrivateIngest() { let messageCount = 200 // 64-hex (stable Noise key) peer ID: skips the short-ID consolidation @@ -354,14 +353,14 @@ final class PerformanceBaselineTests: XCTestCase { for message in messages { fixture.viewModel.handlePrivateMessage(message) } - // Drain the main queue until the debounced LegacyConversationStore sync - // ran and the PrivateInboxModel mirror caught up. + // Reads are synchronous against the single-writer store; the + // spin covers any coordinator main-actor hops. let consistent = spinMainRunLoop(timeout: 10) { - fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount - && fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount + fixture.conversations.conversationsByID[.directPeer(peerID)]?.messages.count == messageCount + && fixture.privateInbox.messages(for: peerID).count == messageCount } samples.append(Date().timeIntervalSince(start)) - XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged") + XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged") XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount) } @@ -374,8 +373,8 @@ final class PerformanceBaselineTests: XCTestCase { /// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry, /// main-actor Task hop per message) → `handlePublicMessage` (rate limit, /// pipeline enqueue) → `PublicMessagePipeline` timer-batched flush into - /// the `ConversationStore` (derived `ChatViewModel.messages` view) → - /// coalesced `LegacyConversationStoreBridge` mirror → `PublicChatModel`. + /// the `ConversationStore` (derived `ChatViewModel.messages` view; + /// `PublicChatModel` observes the active `Conversation` directly). /// Measures until `messages` and the feature model reflect every message, /// so the pipeline's flush latency is part of the cycle. Senders are /// spread 4-per-peer to stay under the 5-token sender rate bucket. @@ -419,8 +418,8 @@ final class PerformanceBaselineTests: XCTestCase { } // Drain the per-message main-actor hops and whichever surfacing // path wins (the pipeline's batched timer flush or the startup - // channel apply's `refreshVisibleMessages`, which pulls the whole - // timeline into `messages`), plus the PublicChatModel mirror. + // channel apply's `refreshVisibleMessages`); `PublicChatModel` + // reads the same conversation synchronously. let consistent = spinMainRunLoop(timeout: 10) { fixture.viewModel.messages.count >= messageCount && fixture.publicChat.messages.count >= messageCount @@ -720,14 +719,14 @@ private final class PerfDeliveryContext: ChatDeliveryContext { /// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style /// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same -/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full -/// current store-synchronization chain. Mirrors the construction used by +/// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover +/// the full ingest-to-feature-model chain. Mirrors the construction used by /// `ChatViewModelExtensionsTests`. @MainActor private final class PerfPipelineFixture { let viewModel: ChatViewModel let transport: MockTransport - let conversationStore: LegacyConversationStore + let conversations: ConversationStore let privateInbox: PrivateInboxModel let publicChat: PublicChatModel @@ -736,19 +735,19 @@ private final class PerfPipelineFixture { let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() - let conversationStore = LegacyConversationStore() + let conversations = ConversationStore() self.transport = transport - self.conversationStore = conversationStore + self.conversations = conversations self.viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport, - conversationStore: conversationStore + conversations: conversations ) - self.privateInbox = PrivateInboxModel(conversationStore: conversationStore) - self.publicChat = PublicChatModel(conversationStore: conversationStore) + self.privateInbox = PrivateInboxModel(conversations: conversations) + self.publicChat = PublicChatModel(conversations: conversations) } } diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 52ef76dd..06d5b1df 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -52,17 +52,17 @@ private func makeSmokeLocationManager() -> LocationChannelManager { @MainActor private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels { let locationManager = makeSmokeLocationManager() - let conversationStore = viewModel.conversationStore - let publicChatModel = PublicChatModel(conversationStore: conversationStore) + let conversations = viewModel.conversations + let publicChatModel = PublicChatModel(conversations: conversations) let locationChannelsModel = LocationChannelsModel(manager: locationManager) - let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + let privateInboxModel = PrivateInboxModel(conversations: conversations) let appChromeModel = AppChromeModel( chatViewModel: viewModel, privateInboxModel: privateInboxModel ) let privateConversationModel = PrivateConversationModel( chatViewModel: viewModel, - conversationStore: conversationStore, + conversations: conversations, locationChannelsModel: locationChannelsModel ) let verificationModel = VerificationModel( @@ -72,11 +72,11 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur let conversationUIModel = ConversationUIModel( chatViewModel: viewModel, privateConversationModel: privateConversationModel, - conversationStore: conversationStore + conversations: conversations ) let peerListModel = PeerListModel( chatViewModel: viewModel, - conversationStore: conversationStore, + conversations: conversations, locationChannelsModel: locationChannelsModel ) diff --git a/docs/CONVERSATION-STORE-DESIGN.md b/docs/CONVERSATION-STORE-DESIGN.md index aa34b406..5aee3e46 100644 --- a/docs/CONVERSATION-STORE-DESIGN.md +++ b/docs/CONVERSATION-STORE-DESIGN.md @@ -1,11 +1,39 @@ # Conversation Store: Single Source of Truth -**Status:** Steps 1–4 implemented (additive store, private cutover, public -cutover, delivery via store; `PublicTimelineStore` and -`ChatDeliveryCoordinator.messageLocationIndex` deleted). Baselines recorded in -`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`, -`pipeline.publicIngest`, `store.append`, `delivery.incrementalUpdate`, -`delivery.storeUpdate`). +**Status: migration complete (steps 1–5).** `ConversationStore` is the sole +holder of message state; the feature models (`PublicChatModel`, +`PrivateInboxModel`, `PrivateConversationModel`, `PeerListModel`, +`ConversationUIModel`) observe it directly — `PublicChatModel` observes the +active `Conversation` object, so background appends never invalidate it. +`LegacyConversationStore`, `LegacyConversationStoreBridge`, and +`PublicTimelineStore` are deleted. Baselines recorded in +`bitchatTests/Performance/PerformanceBaselineTests.swift` +(`pipeline.privateIngest`, `pipeline.publicIngest`, `store.append`, +`delivery.incrementalUpdate`, `delivery.storeUpdate`). + +Deviations from the plan below, chosen at cutover: + +- **No `IdentityResolver` canonicalization layer.** Direct conversations stay + keyed by raw routing `PeerID` (`ConversationID.directPeer`). The + coordinators' ephemeral↔stable mirroring/consolidation guarantees the + selected peer's key always holds the full timeline, and no view enumerates + direct conversations as a list — the legacy resolver-keyed dedup only ever + fed `isEmpty`-style badge checks (identity-aware unread resolution lives in + `ChatUnreadStateResolver`, which works on raw keys). `IdentityResolver` was + deleted with the legacy store; `PeerHandle` slimmed to `id` + + `routingPeerID`. +- **Selection state lives in the store.** The legacy store also carried the + two UI selection axes (`activeChannel`, `selectedPrivatePeerID`); they + moved into `ConversationStore` (`setActiveChannel` / + `setSelectedPrivatePeer`, deriving `selectedConversationID`), and + `migrateConversation` hands the private-peer selection off with the + conversation. +- **`ChatViewModel.messages` / `privateChats` / `unreadPrivateMessages` + survive as derived read-only views** (not stored properties): coordinators, + commands, and tests read them; the dict shape is only rebuilt where a + coordinator genuinely needs the whole dictionary (migration scans, unread + resolution). Simple per-peer reads dispatch through the store-direct + `privateMessages(for:)` context witness instead. --- @@ -96,7 +124,7 @@ while a timer-batched `PublicMessagePipeline` mutates `messages` ~80 ms later is one copy, "await the sync" disappears: after `append` returns, every observer of that `Conversation` sees the message. -## 3. Deleted at end state +## 3. Deleted at end state (done) - `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into `Conversation` cap/dedup policy. @@ -113,7 +141,7 @@ while a timer-batched `PublicMessagePipeline` mutates `messages` ~80 ms later `PublicChatModel.messages` (they observe `Conversation` objects directly). - `ChatViewModel.messages` / `ChatViewModel.privateChats` as stored/owning properties. -## 4. Migration plan +## 4. Migration plan (complete) Each step lands green against the full suite plus the `PerformanceBaselineTests` numbers (no pipeline throughput regression at any step).