From 45650854e7efb6c62acc8eabb8237fd3e1de6585 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 10:51:24 +0200 Subject: [PATCH 1/7] Add conversation-store design doc and end-to-end ingest baselines docs/CONVERSATION-STORE-DESIGN.md records the approved design: a single-writer ConversationStore of per-conversation ObservableObjects (per-conversation publishing, incremental ID index, folded caps, typed change subject) replacing today's four-store/three-bridge topology, with a five-step migration plan and explicit deletions/non-goals. New pipeline benchmarks measure the CURRENT architecture end-to-end so every migration step is judged against real before-numbers: pipeline.privateIngest ~9.7k msg/s, pipeline.publicIngest ~6.8k msg/s (200-message passes, stable within 1.5%). Co-Authored-By: Claude Fable 5 --- .../PerformanceBaselineTests.swift | 169 ++++++++++++++++++ docs/CONVERSATION-STORE-DESIGN.md | 138 ++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 docs/CONVERSATION-STORE-DESIGN.md diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index c598d201..418b75e2 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -328,6 +328,139 @@ final class PerformanceBaselineTests: XCTestCase { reportThroughput("formatting.formatMessage", samples: samples, operations: batchSize, unit: "messages") } + // 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 `ConversationStore.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). + func testPipelinePrivateIngest() { + let messageCount = 200 + // 64-hex (stable Noise key) peer ID: skips the short-ID consolidation + // and ephemeral-mirror paths so every pass does identical work. + let peerID = PeerID(str: String(repeating: "ab", count: 32)) + let base = Date(timeIntervalSince1970: 1_700_000_000) + let messages: [BitchatMessage] = (0..= messageCount + && fixture.publicChat.messages.count >= messageCount + } + samples.append(Date().timeIntervalSince(start)) + XCTAssertTrue(consistent, "messages/PublicChatModel never converged") + let ingested = fixture.viewModel.messages.filter { expectedIDs.contains($0.id) } + XCTAssertEqual(ingested.count, messageCount) + } + + reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages") + } + + /// Spins the main run loop in small slices (draining main-queue tasks and + /// timers) until `condition` holds or `timeout` elapses. + private func spinMainRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + guard Date() < deadline else { return false } + RunLoop.current.run(until: Date().addingTimeInterval(0.005)) + } + return true + } + // MARK: - Fixtures /// Builds deterministic signed kind-20000 geohash events. Content cycles @@ -517,6 +650,42 @@ private final class PerfDeliveryContext: ChatDeliveryContext { } } +// MARK: - End-to-end pipeline fixture + +/// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style +/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same +/// `ConversationStore`, so end-to-end ingest benchmarks cover the full +/// current store-synchronization chain. Mirrors the construction used by +/// `ChatViewModelExtensionsTests`. +@MainActor +private final class PerfPipelineFixture { + let viewModel: ChatViewModel + let transport: MockTransport + let conversationStore: ConversationStore + let privateInbox: PrivateInboxModel + let publicChat: PublicChatModel + + init() { + let keychain = MockKeychain() + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let identityManager = MockIdentityManager(keychain) + let transport = MockTransport() + let conversationStore = ConversationStore() + + self.transport = transport + self.conversationStore = conversationStore + self.viewModel = ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + transport: transport, + conversationStore: conversationStore + ) + self.privateInbox = PrivateInboxModel(conversationStore: conversationStore) + self.publicChat = PublicChatModel(conversationStore: conversationStore) + } +} + // MARK: - Mock MessageFormattingContext @MainActor diff --git a/docs/CONVERSATION-STORE-DESIGN.md b/docs/CONVERSATION-STORE-DESIGN.md new file mode 100644 index 00000000..7b01e7e9 --- /dev/null +++ b/docs/CONVERSATION-STORE-DESIGN.md @@ -0,0 +1,138 @@ +# Conversation Store: Single Source of Truth + +**Status:** Approved design, not yet implemented. Baselines recorded in +`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`, +`pipeline.publicIngest`). + +--- + +## 1. Problem + +Message state is replicated across four stores, kept eventually consistent by three +async bridges. One inbound private message today: + +1. `ChatPrivateConversationCoordinator.handlePrivateMessage` writes through + `ChatViewModel.privateChats` — a passthrough computed property + (`ChatViewModel.swift:167-173`) into `PrivateChatManager`'s + `@Published var privateChats` (`PrivateChatManager.swift:16`). +2. **Bridge 1:** the bootstrapper subscribes `privateChatManager.$privateChats` and + `$unreadMessages` with `.receive(on: DispatchQueue.main)` sinks + (`ChatViewModelBootstrapper.swift:92-108`). +3. **Bridge 2:** each sink calls `schedulePrivateConversationStoreSynchronization`, + a `Task.yield`-debounced task (`ChatViewModel.swift:1084-1092`) that eventually runs + `synchronizePrivateConversationStore` (`ChatViewModel.swift:1095-1101`). +4. That calls `ConversationStore.synchronizePrivateChats` — a **full-dict replace**: every + conversation is re-normalized (dedup + `O(n log n)` sort) and diff-compared on every + sync (`AppArchitecture.swift:304-346`, `normalized` at `AppArchitecture.swift:359-372`). +5. **Bridge 3:** `PrivateInboxModel` subscribes `conversationStore.$messagesByConversation` + (again `.receive(on: DispatchQueue.main)`) and rebuilds its entire + `messagesByPeerID` dictionary via `refreshMessages` + (`PrivateConversationModels.swift:43-48`, `54-68`). +6. SwiftUI finally observes the feature model. + +Costs and hazards: + +- **O(total messages) × 3 layers per single message.** One append re-sorts, re-compares, + and re-publishes every conversation through store and feature-model layers. The ingest + path itself is also quadratic: `isDuplicateMessage` linearly scans *all* private chats + per inbound message (`ChatPrivateConversationCoordinator.swift:622-630`) and + `sanitizeChat` re-sorts the whole chat per append (`PrivateChatManager.swift:213-234`). +- **Delivery status mutates two copies.** `ChatDeliveryCoordinator` patches both + `context.messages` and a value-copied `context.privateChats` + (`ChatDeliveryCoordinator.swift:105-139`), navigating with a positional + `messageLocationIndex` (`ChatDeliveryCoordinator.swift:40`) that any non-append + mutation invalidates, forcing a full rebuild over every message location + (`ChatDeliveryCoordinator.swift:298-320`). +- **Transient disagreement.** Between the `@Published` write and the debounced sync, + `privateChatManager.privateChats` and `ConversationStore.messagesByConversation` + disagree; anything reading the store mid-flight sees stale data. + +The public path has the same shape: `@Published var messages` +(`ChatViewModel.swift:122`) is the render copy, `PublicTimelineStore` +(`ChatViewModel.swift:342-345`) is the backing copy, and `handlePublicMessage` appends to +the timeline, then full-replaces the conversation store **per message** +(`ChatPublicConversationCoordinator.swift:504-545` calling +`synchronizePublicConversationStore` at `:358-364`, which funnels into +`ConversationStore.replaceMessages`'s whole-array compare at `AppArchitecture.swift:249-253`), +while a timer-batched `PublicMessagePipeline` mutates `messages` ~80 ms later +(`PublicMessagePipeline.swift`, `TransportConfig.basePublicFlushInterval`). +`PublicChatModel` then mirrors the store again (`PublicChatModel.swift`). + +## 2. Design + +`ConversationStore` (already `@MainActor` and owned by `AppRuntime`, +`AppRuntime.swift:46`) becomes the **sole writer and sole holder** of message state. + +- **`Conversation` is a reference-type `ObservableObject`**, one instance per + `ConversationID` (`.mesh` / `.geohash` / `.direct`), with `@Published private(set)` + `messages` and unread state. Each conversation maintains its message-ID index + **incrementally** (insert on append, never rebuilt from scratch) and owns its cap + policy: `TransportConfig.meshTimelineCap` / `geoTimelineCap` / `privateChatCap` + (`TransportConfig.swift:17-19`) fold into the store; `PublicTimelineStore`'s trim logic + and `PrivateChatManager`'s cap disappear. +- **Publishing granularity is per conversation.** Views observe ONE `Conversation` + object. An append to chat A never invalidates observers of chat B — unlike today, + where any write republishes the entire `messagesByConversation` dictionary + (`AppArchitecture.swift:205`) and every bound feature model rebuilds. +- **Store-level `changes: PassthroughSubject`** for non-UI + consumers (delivery tracking, notifications, gossip/sync) that need "a message was + appended / status changed in conversation X" without subscribing to message arrays. +- **Mutations go through an intent API only**, mirroring the codebase's existing + single-writer intent ops (`ChatViewModel.swift:421-424`, the `private(set)` + + dedicated-mutator pattern): + - `append(_:to:)` — incremental, dedup via the ID index + - `upsertByID(_:in:)` — replace-or-append (media progress, edits) + - `setDeliveryStatus(_:for:in:)` — keyed by message ID, no positional index + - `markRead(_:)` / `markUnread(_:)` + - `migrateConversation(from:to:)` — the ephemeral↔stable peer-ID handoff that today + is hand-rolled dictionary surgery in three places + - `clear(_:)` + Backing collections are `private(set)`; coordinators receive the intent surface, not + the dictionaries. +- **Reads are synchronous.** Because writers and readers share the main actor and there + is one copy, "await the sync" disappears: after `append` returns, every observer of + that `Conversation` sees the message. + +## 3. Deleted at end state + +- `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into + `Conversation` cap/dedup policy. +- `PrivateChatManager`'s message dict and trim/sanitize logic — the manager shrinks to + read-receipt policy (`markAsRead`, `syncReadReceiptsForSentMessages`). +- `ChatDeliveryCoordinator.messageLocationIndex` and its growth/rebuild machinery + (`ChatDeliveryCoordinator.swift:40-45`, `221-320`) — replaced by + `setDeliveryStatus(for:in:)` against the per-conversation ID index. +- Both bootstrapper sync bridges (`ChatViewModelBootstrapper.swift:92-108`). +- `schedulePrivateConversationStoreSynchronization` / + `synchronizePrivateConversationStore` and the public equivalents + (`ChatViewModel.swift:1084-1101`, `ChatPublicConversationCoordinator.swift:351-386`). +- Feature-model mirror collections: `PrivateInboxModel.messagesByPeerID`, + `PublicChatModel.messages` (they observe `Conversation` objects directly). +- `ChatViewModel.messages` / `ChatViewModel.privateChats` as stored/owning properties. + +## 4. Migration plan + +Each step lands green against the full suite plus the `PerformanceBaselineTests` +numbers (no pipeline throughput regression at any step). + +1. **Additive store.** Introduce `Conversation` objects and the intent API inside + `ConversationStore` alongside the existing replace-based API. Nothing reads them yet. +2. **Private cutover with compat shims.** Inbound/outbound private paths write through + the intent API. `ChatViewModel.privateChats` becomes a derived **read-only** view of + the store; `PrivateChatManager`'s dict and the private sync bridges are bypassed but + the property surface stays so coordinators/tests compile unchanged. +3. **Public cutover.** `handlePublicMessage` and the `PublicMessagePipeline` flush write + to the store; `PublicTimelineStore` folds in; `ChatViewModel.messages` becomes a + derived view of the active conversation. +4. **Delivery via store.** `ChatDeliveryCoordinator` switches to + `setDeliveryStatus(for:in:)`; `messageLocationIndex` is deleted. +5. **View cutover.** Views and feature models observe `Conversation` objects directly; + delete all shims, mirrors, and the replace-based store API. + +## 5. Non-goals + +- **No message persistence.** bitchat is ephemeral by design; the store stays in-memory. +- **`sentReadReceipts` UserDefaults persistence stays put** (`ChatViewModel.swift:394-406`); + it is receipt-protocol state, not conversation state. +- **`MessageRouter`'s outbox remains the SSOT for unsent messages**; the store records + delivery status but never owns retry/resend queues. From ac3a2f2d3482ec47579af530f882cfbd1a4efe88 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 11:02:27 +0200 Subject: [PATCH 2/7] Add single-writer ConversationStore core (additive) Per-conversation ObservableObjects with O(1) dedup via an incrementally maintained message-ID index, binary-search timestamp insertion, folded cap policies, a no-downgrade delivery rule, and a typed change subject. All mutation flows through store intents (conversation mutators are fileprivate). The previous store is renamed LegacyConversationStore pending deletion in step 5. 16 behavioral tests including per- conversation publish isolation; store.append benchmarks at ~144k messages/sec. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppArchitecture.swift | 2 +- bitchat/App/AppRuntime.swift | 4 +- bitchat/App/ConversationStore.swift | 373 ++++++++++++++ bitchat/App/ConversationUIModel.swift | 4 +- bitchat/App/PeerListModel.swift | 4 +- bitchat/App/PrivateConversationModels.swift | 8 +- bitchat/App/PublicChatModel.swift | 4 +- bitchat/ViewModels/ChatViewModel.swift | 10 +- bitchatTests/AppArchitectureTests.swift | 24 +- bitchatTests/ConversationStoreTests.swift | 471 ++++++++++++++++++ .../PerformanceBaselineTests.swift | 52 +- 11 files changed, 919 insertions(+), 37 deletions(-) create mode 100644 bitchat/App/ConversationStore.swift create mode 100644 bitchatTests/ConversationStoreTests.swift diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 829e79ec..6d2c33e0 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -197,7 +197,7 @@ final class IdentityResolver { } @MainActor -final class ConversationStore: ObservableObject { +final class LegacyConversationStore: ObservableObject { @Published private(set) var activeChannel: ChannelID = .mesh @Published private(set) var selectedPrivatePeerID: PeerID? @Published private(set) var selectedConversationID: ConversationID = .mesh diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index f4aba8c8..777e00e6 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -14,7 +14,7 @@ import AppKit final class AppRuntime: ObservableObject { let chatViewModel: ChatViewModel let events = AppEventStream() - let conversationStore: ConversationStore + let conversationStore: LegacyConversationStore let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore let publicChatModel: PublicChatModel @@ -43,7 +43,7 @@ final class AppRuntime: ObservableObject { ) { self.idBridge = idBridge let identityResolver = IdentityResolver() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() let peerIdentityStore = PeerIdentityStore() let locationPresenceStore = LocationPresenceStore() let locationManager = LocationChannelManager.shared diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift new file mode 100644 index 00000000..417574b8 --- /dev/null +++ b/bitchat/App/ConversationStore.swift @@ -0,0 +1,373 @@ +// +// ConversationStore.swift +// bitchat +// +// Single source of truth for conversation message state (see +// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per +// `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. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Combine +import Foundation + +// MARK: - Conversation + +/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`). +/// +/// Publishing granularity is per conversation: views observe ONE +/// `Conversation` object, so an append to chat A never invalidates observers +/// of chat B. +/// +/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent +/// API may mutate a conversation, keeping the store the sole writer. +@MainActor +final class Conversation: ObservableObject, Identifiable { + let id: ConversationID + /// Maximum retained messages; oldest are trimmed on overflow. + let cap: Int + + @Published private(set) var messages: [BitchatMessage] = [] + @Published private(set) var isUnread: Bool = false + + /// Incrementally-maintained message-ID → index map for O(1) dedup and + /// delivery-status lookup. Kept in sync on every mutation: + /// - tail append: single insert + /// - out-of-order insert: suffix reindex from the insertion point + /// - trim: full rebuild — `removeFirst(k)` is already O(n), so the + /// rebuild does not change the asymptotics, and trim only happens once + /// the cap (1337) is reached. Simple and correct beats the + /// offset-tracking alternative here. + private var indexByMessageID: [String: Int] = [:] + + fileprivate init(id: ConversationID, cap: Int) { + self.id = id + self.cap = max(1, cap) + } + + // MARK: Reads + + func containsMessage(withID messageID: String) -> Bool { + indexByMessageID[messageID] != nil + } + + func message(withID messageID: String) -> BitchatMessage? { + guard let index = indexByMessageID[messageID] else { return nil } + return messages[index] + } + + // MARK: Store-internal mutations + + fileprivate enum UpsertOutcome { + case appended + case updated + } + + /// Inserts a message in timestamp order, deduplicating by message ID. + /// Fast path appends when the timestamp is >= the current tail; + /// otherwise a binary search finds the upper-bound insertion point so + /// arrival order is preserved among equal timestamps. + /// Returns `false` if a message with the same ID already exists. + fileprivate func insert(_ message: BitchatMessage) -> Bool { + guard indexByMessageID[message.id] == nil else { return false } + + if let last = messages.last, message.timestamp < last.timestamp { + let index = insertionIndex(for: message.timestamp) + messages.insert(message, at: index) + reindex(from: index) + } else { + messages.append(message) + indexByMessageID[message.id] = messages.count - 1 + } + + trimIfNeeded() + return true + } + + /// Replace-or-append by message ID. An existing message keeps its + /// timeline position (in-place updates like media progress reuse the + /// original timestamp); a new message goes through ordered insertion. + fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { + if let index = indexByMessageID[message.id] { + messages[index] = message + return .updated + } + _ = insert(message) + return .appended + } + + /// Applies a delivery status keyed by message ID, honoring the + /// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`): + /// equal statuses are skipped, and `.read` is never downgraded to + /// `.delivered` or `.sent`. + /// Returns `true` when the status was applied. + fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { + guard let index = indexByMessageID[messageID] else { return false } + let message = messages[index] + guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } + + message.deliveryStatus = status + // BitchatMessage is a reference type; write back through the + // subscript so the @Published wrapper emits. + messages[index] = message + return true + } + + @discardableResult + fileprivate func setUnread(_ unread: Bool) -> Bool { + guard isUnread != unread else { return false } + isUnread = unread + return true + } + + fileprivate func clearMessages() { + messages.removeAll() + indexByMessageID.removeAll() + } + + // MARK: Internals + + static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool { + guard let current else { return false } + if current == new { return true } + + switch (current, new) { + case (.read, .delivered), (.read, .sent): + return true + default: + return false + } + } + + /// Upper-bound binary search: first index whose timestamp is strictly + /// greater than `timestamp`, so equal-timestamp messages keep arrival + /// order. + private func insertionIndex(for timestamp: Date) -> Int { + var low = 0 + var high = messages.count + while low < high { + let mid = (low + high) / 2 + if messages[mid].timestamp <= timestamp { + low = mid + 1 + } else { + high = mid + } + } + return low + } + + private func reindex(from start: Int) { + for index in start.. cap else { return } + let overflow = messages.count - cap + for message in messages.prefix(overflow) { + indexByMessageID.removeValue(forKey: message.id) + } + messages.removeFirst(overflow) + reindex(from: 0) + } +} + +// MARK: - ConversationChange + +/// Typed mutation events for non-UI consumers (delivery tracking, +/// notifications, sync) that need "something changed in conversation X" +/// without subscribing to whole message arrays. Emitted on the store's +/// `changes` subject AFTER the corresponding state is consistent. +enum ConversationChange { + case appended(ConversationID, BitchatMessage) + case updated(ConversationID, messageID: String) + case statusChanged(ConversationID, messageID: String, DeliveryStatus) + case cleared(ConversationID) + case removed(ConversationID) + case migrated(from: ConversationID, to: ConversationID) + case unreadChanged(ConversationID, isUnread: Bool) +} + +// MARK: - ConversationStore + +/// Sole writer and sole holder of conversation message state. All mutations +/// go through the intent API below; backing collections are `private(set)`. +/// Reads are synchronous — writers and readers share the main actor, so +/// after an intent returns every observer sees the result. +@MainActor +final class ConversationStore: ObservableObject { + /// Conversation creation order; published so list-style consumers can + /// observe conversations appearing/disappearing without rebuilding from + /// the dictionary. + @Published private(set) var conversationIDs: [ConversationID] = [] + @Published private(set) var selectedConversationID: ConversationID? + @Published private(set) var unreadConversations: Set = [] + + private(set) var conversationsByID: [ConversationID: Conversation] = [:] + + let changes = PassthroughSubject() + + // MARK: Intent API + + /// Returns the conversation for `id`, creating it (with the cap policy + /// for its kind) on first access. + @discardableResult + func conversation(for id: ConversationID) -> Conversation { + if let existing = conversationsByID[id] { + return existing + } + let conversation = Conversation(id: id, cap: Self.cap(for: id)) + conversationsByID[id] = conversation + conversationIDs.append(id) + return conversation + } + + /// Appends a message in timestamp order. Returns `false` (and emits + /// nothing) if a message with the same ID is already present. + @discardableResult + func append(_ message: BitchatMessage, to id: ConversationID) -> Bool { + let conversation = conversation(for: id) + guard conversation.insert(message) else { return false } + changes.send(.appended(id, message)) + return true + } + + /// Replace-or-append by message ID (media progress, edits). + func upsertByID(_ message: BitchatMessage, in id: ConversationID) { + let conversation = conversation(for: id) + switch conversation.upsert(message) { + case .appended: + changes.send(.appended(id, message)) + case .updated: + changes.send(.updated(id, messageID: message.id)) + } + } + + /// Applies a delivery status keyed by message ID. Returns `false` when + /// the message is unknown or the update would downgrade the status + /// (read beats delivered beats sent). + @discardableResult + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool { + guard let conversation = conversationsByID[id], + conversation.applyDeliveryStatus(status, forMessageID: messageID) else { + return false + } + changes.send(.statusChanged(id, messageID: messageID, status)) + return true + } + + func markRead(_ id: ConversationID) { + guard unreadConversations.contains(id) else { return } + unreadConversations.remove(id) + conversationsByID[id]?.setUnread(false) + changes.send(.unreadChanged(id, isUnread: false)) + } + + func markUnread(_ id: ConversationID) { + guard !unreadConversations.contains(id) else { return } + let conversation = conversation(for: id) + unreadConversations.insert(id) + conversation.setUnread(true) + changes.send(.unreadChanged(id, isUnread: true)) + } + + /// Selects a conversation (creating it if needed) or clears the + /// selection with `nil`. + func select(_ id: ConversationID?) { + if let id { + conversation(for: id) + } + guard selectedConversationID != id else { return } + selectedConversationID = id + } + + /// 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 + /// selection — mirroring `ChatPrivateConversationCoordinator`'s + /// migration semantics. The source conversation is removed. Emits a + /// single `.migrated(from:to:)` once the whole move is consistent. + func migrateConversation(from source: ConversationID, to destination: ConversationID) { + guard source != destination, let sourceConversation = conversationsByID[source] else { return } + + let destinationConversation = conversation(for: destination) + for message in sourceConversation.messages { + _ = destinationConversation.insert(message) + } + + let wasUnread = unreadConversations.contains(source) + let wasSelected = selectedConversationID == source + + conversationsByID.removeValue(forKey: source) + conversationIDs.removeAll { $0 == source } + unreadConversations.remove(source) + + if wasUnread, !unreadConversations.contains(destination) { + unreadConversations.insert(destination) + destinationConversation.setUnread(true) + } + if wasSelected { + selectedConversationID = destination + } + + changes.send(.migrated(from: source, to: destination)) + } + + /// Empties a conversation's timeline but keeps the conversation (and + /// its unread/selection state) alive. + func clear(_ id: ConversationID) { + guard let conversation = conversationsByID[id] else { return } + conversation.clearMessages() + changes.send(.cleared(id)) + } + + /// Removes a conversation entirely, including unread state; clears the + /// selection if it pointed at the removed conversation. + func removeConversation(_ id: ConversationID) { + guard conversationsByID.removeValue(forKey: id) != nil else { return } + conversationIDs.removeAll { $0 == id } + unreadConversations.remove(id) + if selectedConversationID == id { + selectedConversationID = nil + } + changes.send(.removed(id)) + } + + func clearAll() { + let removedIDs = conversationIDs + guard !removedIDs.isEmpty || selectedConversationID != nil else { return } + + conversationsByID.removeAll() + conversationIDs.removeAll() + unreadConversations.removeAll() + if selectedConversationID != nil { + selectedConversationID = nil + } + + for id in removedIDs { + changes.send(.removed(id)) + } + } + + // MARK: Internals + + private static func cap(for id: ConversationID) -> Int { + switch id { + case .mesh: + return TransportConfig.meshTimelineCap + case .geohash: + return TransportConfig.geoTimelineCap + case .direct: + return TransportConfig.privateChatCap + } + } +} diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 01888d35..120077fb 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -15,14 +15,14 @@ final class ConversationUIModel: ObservableObject { private let chatViewModel: ChatViewModel private let privateConversationModel: PrivateConversationModel - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private var activeChannel: ChannelID private var cancellables = Set() init( chatViewModel: ChatViewModel, privateConversationModel: PrivateConversationModel, - conversationStore: ConversationStore + conversationStore: LegacyConversationStore ) { self.chatViewModel = chatViewModel self.privateConversationModel = privateConversationModel diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 91993d47..90a8ea2b 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: ConversationStore + private let conversationStore: LegacyConversationStore private let locationChannelsModel: LocationChannelsModel private let peerIdentityStore: PeerIdentityStore private let locationPresenceStore: LocationPresenceStore @@ -45,7 +45,7 @@ final class PeerListModel: ObservableObject { init( chatViewModel: ChatViewModel, - conversationStore: ConversationStore, + conversationStore: LegacyConversationStore, locationChannelsModel: LocationChannelsModel? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 412c1ad2..cf50037f 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -8,10 +8,10 @@ final class PrivateInboxModel: ObservableObject { @Published private(set) var unreadPeerIDs: Set = [] @Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private var cancellables = Set() - init(conversationStore: ConversationStore) { + init(conversationStore: LegacyConversationStore) { self.conversationStore = conversationStore bind() @@ -94,14 +94,14 @@ final class PrivateConversationModel: ObservableObject { @Published private(set) var selectedHeaderState: PrivateConversationHeaderState? private let chatViewModel: ChatViewModel - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private let locationChannelsModel: LocationChannelsModel private let peerIdentityStore: PeerIdentityStore private var cancellables = Set() init( chatViewModel: ChatViewModel, - conversationStore: ConversationStore, + conversationStore: LegacyConversationStore, locationChannelsModel: LocationChannelsModel? = nil, peerIdentityStore: PeerIdentityStore? = nil ) { diff --git a/bitchat/App/PublicChatModel.swift b/bitchat/App/PublicChatModel.swift index 0d225bdb..48159b71 100644 --- a/bitchat/App/PublicChatModel.swift +++ b/bitchat/App/PublicChatModel.swift @@ -7,10 +7,10 @@ final class PublicChatModel: ObservableObject { @Published private(set) var activeChannel: ChannelID @Published private(set) var messages: [BitchatMessage] = [] - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private var cancellables = Set() - init(conversationStore: ConversationStore) { + init(conversationStore: LegacyConversationStore) { self.activeChannel = conversationStore.activeChannel self.conversationStore = conversationStore diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4318ce1e..fbd3daab 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -270,7 +270,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol - let conversationStore: ConversationStore + let conversationStore: LegacyConversationStore let identityResolver: IdentityResolver let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore @@ -532,13 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - conversationStore: ConversationStore? = nil, + conversationStore: LegacyConversationStore? = nil, identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let conversationStore = conversationStore ?? ConversationStore() + let conversationStore = conversationStore ?? LegacyConversationStore() let identityResolver = identityResolver ?? IdentityResolver() self.init( keychain: keychain, @@ -561,13 +561,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, transport: Transport, - conversationStore: ConversationStore? = nil, + conversationStore: LegacyConversationStore? = nil, identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let conversationStore = conversationStore ?? ConversationStore() + let conversationStore = conversationStore ?? LegacyConversationStore() let identityResolver = identityResolver ?? IdentityResolver() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index c58a6fd4..975d2ec3 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -146,10 +146,10 @@ struct AppArchitectureTests { #expect(Set([first, second]).count == 1) } - @Test("ConversationStore normalizes timeline ordering and duplicates") + @Test("LegacyConversationStore normalizes timeline ordering and duplicates") @MainActor func conversationStoreNormalizesMessages() { - let store = ConversationStore() + let store = LegacyConversationStore() let older = BitchatMessage( id: "m1", sender: "alice", @@ -191,10 +191,10 @@ struct AppArchitectureTests { #expect(messages.last?.content == "second-updated") } - @Test("ConversationStore tracks unread direct conversations with canonical IDs") + @Test("LegacyConversationStore tracks unread direct conversations with canonical IDs") @MainActor func conversationStoreTracksUnreadDirectConversations() { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let peerID = PeerID(str: "peer-1") let message = BitchatMessage( @@ -226,10 +226,10 @@ struct AppArchitectureTests { #expect(!store.unreadConversations.contains(conversationID)) } - @Test("ConversationStore tracks the selected app conversation context") + @Test("LegacyConversationStore tracks the selected app conversation context") @MainActor func conversationStoreTracksSelectedConversationContext() { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let noiseKey = Data((0..<32).map(UInt8.init)) let shortPeerID = PeerID(str: "0011223344556677") @@ -268,10 +268,10 @@ struct AppArchitectureTests { #expect(store.selectedConversationID == ConversationID.mesh) } - @Test("ConversationStore exposes direct conversations by the latest routing peer ID") + @Test("LegacyConversationStore exposes direct conversations by the latest routing peer ID") @MainActor func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let noiseKey = Data((0..<32).map(UInt8.init)) let shortPeerID = PeerID(str: "0011223344556677") @@ -334,10 +334,10 @@ struct AppArchitectureTests { #expect(store.unreadDirectPeerIDs() == Set([fullPeerID])) } - @Test("PrivateInboxModel mirrors direct message state from ConversationStore") + @Test("PrivateInboxModel mirrors direct message state from LegacyConversationStore") @MainActor func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let inboxModel = PrivateInboxModel(conversationStore: store) let messagePeerID = PeerID(str: "peer-1") @@ -383,7 +383,7 @@ struct AppArchitectureTests { @MainActor func appChromeModelMirrorsNicknameAndUnreadState() async { let viewModel = makeArchitectureViewModel() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() let resolver = IdentityResolver() let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) @@ -414,7 +414,7 @@ struct AppArchitectureTests { @MainActor func appChromeModelOwnsPresentationState() { let viewModel = makeArchitectureViewModel() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) let peerID = PeerID(str: "peer-2") diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift new file mode 100644 index 00000000..4a805134 --- /dev/null +++ b/bitchatTests/ConversationStoreTests.swift @@ -0,0 +1,471 @@ +// +// ConversationStoreTests.swift +// bitchatTests +// +// Tests for the new single-source-of-truth ConversationStore +// (docs/CONVERSATION-STORE-DESIGN.md): intent API, ordered insertion, +// dedup, caps, delivery-status rules, migration, unread state, change +// emission, and per-conversation publish isolation. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Combine +import Foundation +import Testing +@testable import bitchat + +@MainActor +private func makeMessage( + id: String, + timestamp: TimeInterval, + content: String? = nil, + isPrivate: Bool = false, + deliveryStatus: DeliveryStatus? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "alice", + content: content ?? "message \(id)", + timestamp: Date(timeIntervalSince1970: timestamp), + isRelay: false, + isPrivate: isPrivate, + recipientNickname: isPrivate ? "bob" : nil, + senderPeerID: PeerID(str: "peer-a"), + deliveryStatus: deliveryStatus + ) +} + +private func makeDirectConversationID(_ suffix: String) -> ConversationID { + .direct(PeerHandle( + id: "noise:\(suffix)", + routingPeerID: PeerID(str: "peer-\(suffix)"), + displayName: "peer \(suffix)", + noisePublicKeyHex: suffix, + nostrPublicKey: nil + )) +} + +@Suite("ConversationStore") +struct ConversationStoreTests { + + // MARK: - Append, dedup, ordering + + @Test("append dedups by message ID and reports duplicates") + @MainActor + func appendDedupsByMessageID() { + let store = ConversationStore() + var received: [ConversationChange] = [] + let cancellable = store.changes.sink { received.append($0) } + defer { cancellable.cancel() } + + #expect(store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)) + #expect(!store.append(makeMessage(id: "m1", timestamp: 2, content: "dup"), to: .mesh)) + + let conversation = store.conversation(for: .mesh) + #expect(conversation.messages.count == 1) + #expect(conversation.messages.first?.content == "message m1") + #expect(conversation.containsMessage(withID: "m1")) + #expect(received.count == 1) + guard case .appended(.mesh, let message) = received.first else { + Issue.record("expected a single .appended change, got \(received)") + return + } + #expect(message.id == "m1") + } + + @Test("out-of-order appends are inserted in timestamp order") + @MainActor + func outOfOrderInsertKeepsTimestampOrder() { + let store = ConversationStore() + + store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh) + store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh) + store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) + store.append(makeMessage(id: "m0", timestamp: 5), to: .mesh) + + let conversation = store.conversation(for: .mesh) + #expect(conversation.messages.map(\.id) == ["m0", "m1", "m2", "m3"]) + + // The ID index must survive middle inserts: lookups by ID still + // resolve to the right message. + #expect(conversation.message(withID: "m2")?.timestamp == Date(timeIntervalSince1970: 20)) + #expect(conversation.message(withID: "m0")?.timestamp == Date(timeIntervalSince1970: 5)) + } + + @Test("equal timestamps preserve arrival order") + @MainActor + func equalTimestampsPreserveArrivalOrder() { + let store = ConversationStore() + + store.append(makeMessage(id: "first", timestamp: 10), to: .mesh) + store.append(makeMessage(id: "second", timestamp: 10), to: .mesh) + // A late message with an equal timestamp lands after existing peers. + store.append(makeMessage(id: "late-tail", timestamp: 20), to: .mesh) + store.append(makeMessage(id: "third", timestamp: 10), to: .mesh) + + let conversation = store.conversation(for: .mesh) + #expect(conversation.messages.map(\.id) == ["first", "second", "third", "late-tail"]) + } + + // MARK: - Caps + + @Test("cap trims oldest messages and keeps the ID index valid") + @MainActor + func capTrimsOldestAndKeepsIndexValid() { + let store = ConversationStore() + let conversation = store.conversation(for: .mesh) + let cap = conversation.cap + #expect(cap == TransportConfig.meshTimelineCap) + + let overflow = 3 + for i in 0..<(cap + overflow) { + store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh) + } + + #expect(conversation.messages.count == cap) + #expect(conversation.messages.first?.id == "m\(overflow)") + #expect(conversation.messages.last?.id == "m\(cap + overflow - 1)") + + // Trimmed messages left the index entirely… + for i in 0..() + conversationA.objectWillChange + .sink { aWillChangeCount += 1 } + .store(in: &cancellables) + conversationB.objectWillChange + .sink { bWillChangeCount += 1 } + .store(in: &cancellables) + + store.append(makeMessage(id: "m1", timestamp: 1), to: a) + store.append(makeMessage(id: "m2", timestamp: 2), to: a) + store.setDeliveryStatus(.sent, forMessageID: "m1", in: a) + store.markUnread(a) + + #expect(aWillChangeCount >= 4) + #expect(bWillChangeCount == 0) + + store.append(makeMessage(id: "m3", timestamp: 3), to: b) + #expect(bWillChangeCount > 0) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 418b75e2..839ceb2d 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -333,7 +333,7 @@ final class PerformanceBaselineTests: XCTestCase { /// 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 `ConversationStore.synchronizePrivateChats` + /// `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 @@ -370,14 +370,14 @@ final class PerformanceBaselineTests: XCTestCase { for message in messages { fixture.viewModel.handlePrivateMessage(message) } - // Drain the main queue until the debounced ConversationStore sync + // Drain the main queue until the debounced LegacyConversationStore sync // ran and the PrivateInboxModel mirror caught up. let consistent = spinMainRunLoop(timeout: 10) { fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount && fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount } samples.append(Date().timeIntervalSince(start)) - XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged") + XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged") XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount) } @@ -390,7 +390,7 @@ final class PerformanceBaselineTests: XCTestCase { /// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry, /// main-actor Task hop per message) → `handlePublicMessage` (rate limit, /// `PublicTimelineStore` append, per-message full-array - /// `ConversationStore` sync) → `PublicMessagePipeline` timer-batched + /// `LegacyConversationStore` sync) → `PublicMessagePipeline` timer-batched /// flush into `ChatViewModel.messages` → `PublicChatModel` mirror. /// Measures until `messages` and the feature model reflect every message, /// so the pipeline's flush latency is part of the cycle. Senders are @@ -450,6 +450,44 @@ final class PerformanceBaselineTests: XCTestCase { reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages") } + // MARK: - 7. ConversationStore append (to-be architecture) + + /// Core op of the new single-source-of-truth `ConversationStore` + /// (docs/CONVERSATION-STORE-DESIGN.md): 1000 appends into one + /// conversation, every 10th arriving out of order so the binary-search + /// insert path (plus suffix reindex) is part of the measured work. + /// Includes per-message ID-index dedup and the per-conversation + /// `@Published` array write. + func testConversationStoreAppend() { + let messageCount = 1000 + let base = Date(timeIntervalSince1970: 1_700_000_000) + let messages: [BitchatMessage] = (0.. Bool) -> Bool { @@ -654,14 +692,14 @@ private final class PerfDeliveryContext: ChatDeliveryContext { /// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style /// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same -/// `ConversationStore`, so end-to-end ingest benchmarks cover the full +/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full /// current store-synchronization chain. Mirrors the construction used by /// `ChatViewModelExtensionsTests`. @MainActor private final class PerfPipelineFixture { let viewModel: ChatViewModel let transport: MockTransport - let conversationStore: ConversationStore + let conversationStore: LegacyConversationStore let privateInbox: PrivateInboxModel let publicChat: PublicChatModel @@ -670,7 +708,7 @@ private final class PerfPipelineFixture { let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() self.transport = transport self.conversationStore = conversationStore From 879d8cba127575ca2c359ae25bb9b2f54a625752 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 12:19:11 +0200 Subject: [PATCH 3/7] Cut private message path over to ConversationStore All private-message mutations now flow through store intents: coordinators, PrivateChatManager (its @Published dicts deleted - now read-only views over the store), outbound sends, delivery status, and chat migration. The O(1) store dedup replaces the full-scan duplicate check; insertion order is maintained by the store so sanitizeChat's re-sort is a documented no-op. Both bootstrapper Combine bridges and the Task.yield store synchronization are deleted. ChatViewModel.privateChats/unreadPrivateMessages become get-only derived views (measured: naive rebuild equals a change-invalidated cache within noise, so the simpler form stays). Feature models still read the legacy store, fed by a coalescing LegacyConversationStoreBridge (one mirror per burst, marked for step-5 deletion). pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%). Co-Authored-By: Claude Fable 5 --- bitchat/App/AppArchitecture.swift | 26 ++ bitchat/App/AppRuntime.swift | 8 + bitchat/App/ConversationStore.swift | 93 ++++++++ .../App/LegacyConversationStoreBridge.swift | 146 ++++++++++++ bitchat/Services/CommandProcessor.swift | 6 +- bitchat/Services/PrivateChatManager.swift | 221 ++++++++--------- .../ViewModels/ChatDeliveryCoordinator.swift | 22 +- .../ViewModels/ChatLifecycleCoordinator.swift | 24 +- .../ChatMediaTransferCoordinator.swift | 9 +- .../ChatPeerIdentityCoordinator.swift | 42 +--- .../ViewModels/ChatPeerListCoordinator.swift | 6 +- .../ChatPrivateConversationCoordinator.swift | 225 +++++++++--------- .../ChatPublicConversationCoordinator.swift | 36 +-- .../ChatTransportEventCoordinator.swift | 34 +-- bitchat/ViewModels/ChatViewModel.swift | 183 +++++++++++--- .../ChatViewModelBootstrapper.swift | 27 +-- bitchatTests/AppArchitectureTests.swift | 2 +- ...ChatLifecycleCoordinatorContextTests.swift | 18 +- ...MediaTransferCoordinatorContextTests.swift | 10 + ...tPeerIdentityCoordinatorContextTests.swift | 27 ++- .../ChatPeerListCoordinatorContextTests.swift | 4 + ...eConversationCoordinatorContextTests.swift | 99 +++++++- ...cConversationCoordinatorContextTests.swift | 28 ++- ...ransportEventCoordinatorContextTests.swift | 24 ++ .../ChatViewModelDeliveryStatusTests.swift | 43 +++- .../ChatViewModelExtensionsTests.swift | 18 +- bitchatTests/ChatViewModelTests.swift | 125 +++++----- bitchatTests/CommandProcessorTests.swift | 6 + .../LegacyConversationStoreBridgeTests.swift | 177 ++++++++++++++ .../PerformanceBaselineTests.swift | 11 + .../Services/PrivateChatManagerTests.swift | 114 ++++----- bitchatTests/TestUtilities/TestHelpers.swift | 22 ++ bitchatTests/ViewSmokeTests.swift | 2 +- 33 files changed, 1293 insertions(+), 545 deletions(-) create mode 100644 bitchat/App/LegacyConversationStoreBridge.swift create mode 100644 bitchatTests/LegacyConversationStoreBridgeTests.swift diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 6d2c33e0..65fcaebf 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -356,6 +356,32 @@ final class LegacyConversationStore: ObservableObject { 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] = [:] diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 777e00e6..db861132 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -15,6 +15,11 @@ 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`. + let conversations: ConversationStore let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore let publicChatModel: PublicChatModel @@ -44,10 +49,12 @@ final class AppRuntime: ObservableObject { 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 self.chatViewModel = ChatViewModel( @@ -55,6 +62,7 @@ final class AppRuntime: ObservableObject { idBridge: idBridge, identityManager: SecureIdentityStateManager(keychain), conversationStore: conversationStore, + conversations: conversations, identityResolver: identityResolver, peerIdentityStore: peerIdentityStore, locationPresenceStore: locationPresenceStore, diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 417574b8..1952b4d8 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -128,6 +128,16 @@ final class Conversation: ObservableObject, Identifiable { return true } + /// Removes a single message by ID. Returns the removed message, or + /// `nil` when no message with that ID exists. + fileprivate func remove(messageID: String) -> BitchatMessage? { + guard let index = indexByMessageID[messageID] else { return nil } + let removed = messages.remove(at: index) + indexByMessageID.removeValue(forKey: messageID) + reindex(from: index) + return removed + } + fileprivate func clearMessages() { messages.removeAll() indexByMessageID.removeAll() @@ -191,6 +201,7 @@ enum ConversationChange { case appended(ConversationID, BitchatMessage) case updated(ConversationID, messageID: String) case statusChanged(ConversationID, messageID: String, DeliveryStatus) + case messageRemoved(ConversationID, messageID: String) case cleared(ConversationID) case removed(ConversationID) case migrated(from: ConversationID, to: ConversationID) @@ -322,6 +333,19 @@ final class ConversationStore: ObservableObject { changes.send(.migrated(from: source, to: destination)) } + /// Removes a single message by ID from a conversation. Returns the + /// removed message, or `nil` (emitting nothing) when the conversation or + /// message is unknown. + @discardableResult + func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? { + guard let conversation = conversationsByID[id], + let removed = conversation.remove(messageID: messageID) else { + return nil + } + changes.send(.messageRemoved(id, messageID: messageID)) + return removed + } + /// Empties a conversation's timeline but keeps the conversation (and /// its unread/selection state) alive. func clear(_ id: ConversationID) { @@ -371,3 +395,72 @@ final class ConversationStore: ObservableObject { } } } + +// MARK: - Migration step 2 compatibility (raw per-peer 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. + static func directPeer(_ peerID: PeerID) -> ConversationID { + .direct(PeerHandle( + id: "peer:\(peerID.id)", + routingPeerID: peerID, + displayName: nil, + noisePublicKeyHex: nil, + nostrPublicKey: nil + )) + } +} + +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). + func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] { + var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] + messagesByPeerID.reserveCapacity(conversationsByID.count) + for (id, conversation) in conversationsByID { + guard case .direct(let handle) = id else { continue } + messagesByPeerID[handle.routingPeerID] = conversation.messages + } + return messagesByPeerID + } + + /// Unread direct conversations as routing peer IDs — the compat shape of + /// the legacy `unreadPrivateMessages` set. + func unreadDirectRoutingPeerIDs() -> Set { + var peerIDs = Set() + for id in unreadConversations { + guard case .direct(let handle) = id else { continue } + peerIDs.insert(handle.routingPeerID) + } + return peerIDs + } + + /// `true` when any direct conversation contains a message with `messageID` + /// (O(1) per conversation via the incremental ID index). + func directConversationsContainMessage(withID messageID: String) -> Bool { + for (id, conversation) in conversationsByID { + guard case .direct = id else { continue } + if conversation.containsMessage(withID: messageID) { return true } + } + return false + } + + /// Removes every direct conversation (panic clear). + func removeAllDirectConversations() { + let directIDs = conversationIDs.filter { id in + if case .direct = id { return true } + return false + } + for id in directIDs { + removeConversation(id) + } + } +} diff --git a/bitchat/App/LegacyConversationStoreBridge.swift b/bitchat/App/LegacyConversationStoreBridge.swift new file mode 100644 index 00000000..7ed57f17 --- /dev/null +++ b/bitchat/App/LegacyConversationStoreBridge.swift @@ -0,0 +1,146 @@ +// +// 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) +// message state; the feature models (`PrivateInboxModel`, +// `PrivateConversationModel`, `ConversationUIModel`, `PeerListModel`) 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 changes (migration/removal) resynchronize immediately. +// Legacy is therefore eventually consistent within one run-loop tick — the +// same visibility the old `$privateChats` sink 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 conversation; pending per-conversation + // work is redundant. + dirtyConversations.removeAll() + 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): + guard isDirect(id) else { return } + resynchronizeAll() + } + } + + func markDirty(_ id: ConversationID) { + guard isDirect(id) else { return } + 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 case .direct(let handle) = id, + let conversation = store.conversationsByID[id] else { + // Removed while dirty; the removal already resynchronized. + return + } + legacyStore.replaceDirectMessages( + conversation.messages, + for: handle.routingPeerID, + identityResolver: identityResolver + ) + } + + func isDirect(_ id: ConversationID) -> Bool { + if case .direct = id { return true } + return false + } +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 99b47288..f435d8e6 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -31,7 +31,7 @@ protocol CommandContextProvider: AnyObject { var activeChannel: ChannelID { get } var selectedPrivateChatPeer: PeerID? { get } var blockedUsers: Set { get } - var privateChats: [PeerID: [BitchatMessage]] { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } var idBridge: NostrIdentityBridge { get } // MARK: - Peer Lookup @@ -43,6 +43,8 @@ protocol CommandContextProvider: AnyObject { func startPrivateChat(with peerID: PeerID) func sendPrivateMessage(_ content: String, to peerID: PeerID) func clearCurrentPublicTimeline() + /// Empties the peer's chat (single-writer store intent for `/clear`). + func clearPrivateChat(_ peerID: PeerID) func sendPublicRaw(_ content: String) // MARK: - System Messages @@ -160,7 +162,7 @@ final class CommandProcessor { private func handleClear() -> CommandResult { if let peerID = contextProvider?.selectedPrivateChatPeer { - contextProvider?.privateChats[peerID]?.removeAll() + contextProvider?.clearPrivateChat(peerID) } else { contextProvider?.clearCurrentPublicTimeline() } diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 3e920e12..62a905d2 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -11,11 +11,14 @@ import BitFoundation import Foundation import SwiftUI -/// Manages all private chat functionality +/// 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). final class PrivateChatManager: ObservableObject { - @Published var privateChats: [PeerID: [BitchatMessage]] = [:] @Published var selectedPeer: PeerID? = nil - @Published var unreadMessages: Set = [] private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel @@ -25,13 +28,34 @@ final class PrivateChatManager: ObservableObject { weak var messageRouter: MessageRouter? // Peer service for looking up peer info during consolidation weak var unifiedPeerService: UnifiedPeerService? + /// Single source of truth for message state; injected by the + /// bootstrapper (`wireServiceGraph`). + var conversationStore: ConversationStore? - init(meshService: Transport? = nil) { + init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) { self.meshService = meshService + self.conversationStore = conversationStore } - // Cap for messages stored per private chat - private let privateChatCap = TransportConfig.privateChatCap + // MARK: - Derived message state (read-only compat views) + + /// All private chats keyed by routing peer ID, derived from the store. + /// Mutations go through the store's intent API only. + @MainActor + var privateChats: [PeerID: [BitchatMessage]] { + conversationStore?.directMessagesByRoutingPeerID() ?? [:] + } + + /// Unread chats, derived from the store's unread state. + @MainActor + var unreadMessages: Set { + conversationStore?.unreadDirectRoutingPeerIDs() ?? [] + } + + @MainActor + private func messages(for peerID: PeerID) -> [BitchatMessage] { + conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? [] + } // MARK: - Message Consolidation @@ -44,57 +68,51 @@ final class PrivateChatManager: ObservableObject { /// - Returns: True if any unread messages were found during consolidation @MainActor func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set) -> Bool { - guard let meshService = meshService else { return false } + guard let meshService = meshService, let store = conversationStore else { return false } var hasUnreadMessages = false // 1. Consolidate from stable Noise key (64-char hex) if let peer = unifiedPeerService?.getPeer(by: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + let nostrMessages = messages(for: noiseKeyHex) - if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty { - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - - let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) + if noiseKeyHex != peerID, !nostrMessages.isEmpty { for message in nostrMessages { - if !existingMessageIds.contains(message.id) { - // Update senderPeerID for correct read receipts - let updatedMessage = BitchatMessage( - id: message.id, - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - privateChats[peerID]?.append(updatedMessage) + // Update senderPeerID for correct read receipts + let updatedMessage = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + // Store append dedups by message ID (skips ones the + // target chat already has). + guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue } - // Check for recent unread messages (< 60s, not sent by us, not already read) - // Use persistedReadReceipts to correctly identify already-read messages after app restart - if message.senderPeerID != meshService.myPeerID { - let messageAge = Date().timeIntervalSince(message.timestamp) - if messageAge < 60 && !persistedReadReceipts.contains(message.id) { - hasUnreadMessages = true - } + // Check for recent unread messages (< 60s, not sent by us, not already read) + // Use persistedReadReceipts to correctly identify already-read messages after app restart + if message.senderPeerID != meshService.myPeerID { + let messageAge = Date().timeIntervalSince(message.timestamp) + if messageAge < 60 && !persistedReadReceipts.contains(message.id) { + hasUnreadMessages = true } } } - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - if hasUnreadMessages { - unreadMessages.insert(peerID) - } else if unreadMessages.contains(noiseKeyHex) { - unreadMessages.remove(noiseKeyHex) + store.markUnread(.directPeer(peerID)) + } else { + store.markRead(.directPeer(noiseKeyHex)) } - privateChats.removeValue(forKey: noiseKeyHex) + store.removeConversation(.directPeer(noiseKeyHex)) } } @@ -112,52 +130,43 @@ final class PrivateChatManager: ObservableObject { } if !tempPeerIDsToConsolidate.isEmpty { - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - - let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) var consolidatedCount = 0 var hadUnreadTemp = false + let unreadPeerIDs = unreadMessages for tempPeerID in tempPeerIDsToConsolidate { - if unreadMessages.contains(tempPeerID) { + if unreadPeerIDs.contains(tempPeerID) { hadUnreadTemp = true } - if let tempMessages = privateChats[tempPeerID] { - for message in tempMessages { - if !existingMessageIds.contains(message.id) { - let updatedMessage = BitchatMessage( - id: message.id, - sender: message.sender, - content: message.content, - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: peerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - privateChats[peerID]?.append(updatedMessage) - consolidatedCount += 1 - } + for message in messages(for: tempPeerID) { + let updatedMessage = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: peerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + if store.append(updatedMessage, to: .directPeer(peerID)) { + consolidatedCount += 1 } - privateChats.removeValue(forKey: tempPeerID) - unreadMessages.remove(tempPeerID) } + store.removeConversation(.directPeer(tempPeerID)) } if hadUnreadTemp { - unreadMessages.insert(peerID) + store.markUnread(.directPeer(peerID)) hasUnreadMessages = true SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session) } if consolidatedCount > 0 { - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session) } } @@ -168,9 +177,7 @@ final class PrivateChatManager: ObservableObject { /// Syncs the read receipt tracking between manager and view model for sent messages @MainActor func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set) { - guard let messages = privateChats[peerID] else { return } - - for message in messages { + for message in messages(for: peerID) { if message.sender == nickname { if let status = message.deliveryStatus { switch status { @@ -184,86 +191,66 @@ final class PrivateChatManager: ObservableObject { } } } - + /// Start a private chat with a peer + @MainActor func startChat(with peerID: PeerID) { selectedPeer = peerID - + // Store fingerprint for persistence across reconnections if let fingerprint = meshService?.getFingerprint(for: peerID) { selectedPeerFingerprint = fingerprint } - + // Mark messages as read markAsRead(from: peerID) - + // Initialize chat if needed - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } + conversationStore?.conversation(for: .directPeer(peerID)) } - + /// End the current private chat func endChat() { selectedPeer = nil selectedPeerFingerprint = nil } - /// Remove duplicate messages by ID and keep chronological order - func sanitizeChat(for peerID: PeerID) { - guard let arr = privateChats[peerID] else { return } - if arr.count <= 1 { - return - } + /// No-op since the `ConversationStore` cutover: the store maintains + /// chronological order and dedups by message ID on every insert, so the + /// per-append re-sort/dedup sweep this performed is no longer needed. + /// Kept only for API compatibility until step 5 removes the callers. + func sanitizeChat(for peerID: PeerID) {} - var indexByID: [String: Int] = [:] - indexByID.reserveCapacity(arr.count) - var deduped: [BitchatMessage] = [] - deduped.reserveCapacity(arr.count) - - for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { - if let existing = indexByID[msg.id] { - deduped[existing] = msg - } else { - indexByID[msg.id] = deduped.count - deduped.append(msg) - } - } - - privateChats[peerID] = deduped - } - /// Mark messages from a peer as read + @MainActor func markAsRead(from peerID: PeerID) { - unreadMessages.remove(peerID) - + conversationStore?.markRead(.directPeer(peerID)) + // Send read receipts for unread messages that haven't been sent yet - if let messages = privateChats[peerID] { - for message in messages { - if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { - sendReadReceipt(for: message) - } + for message in messages(for: peerID) { + if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { + sendReadReceipt(for: message) } } } - + // MARK: - Private Methods - + private func sendReadReceipt(for message: BitchatMessage) { guard !sentReadReceipts.contains(message.id), let senderPeerID = message.senderPeerID else { return } - + sentReadReceipts.insert(message.id) - + // Create read receipt using the simplified method let receipt = ReadReceipt( originalMessageID: message.id, readerID: meshService?.myPeerID ?? PeerID(str: ""), readerNickname: meshService?.myNickname ?? "" ) - + // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index 61a4c8e3..e94a5a73 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -13,8 +13,13 @@ import Foundation @MainActor protocol ChatDeliveryContext: AnyObject { var messages: [BitchatMessage] { get set } - var privateChats: [PeerID: [BitchatMessage]] { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } var isStartupPhase: Bool { get } + /// Applies a delivery status to a private message by ID (single-writer + /// store intent; full delivery migration is step 4). Returns `false` + /// when the message is unknown or the update would downgrade the status. + @discardableResult + func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool /// Drops every recorded read receipt whose message ID is not in `validMessageIDs`. /// Returns the number of receipts removed. (Single mutation path for the /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) @@ -98,7 +103,6 @@ final class ChatDeliveryCoordinator { } var didUpdateStatus = false - var didUpdatePrivateStatus = false let locations = withValidLocations(for: messageID) { $0 } guard !locations.isEmpty else { return false } @@ -116,10 +120,9 @@ final class ChatDeliveryCoordinator { } } - var privateChats = context.privateChats for location in locations { guard case .privateChat(let peerID, let index) = location, - let chatMessages = privateChats[peerID], + let chatMessages = context.privateChats[peerID], index < chatMessages.count, chatMessages[index].id == messageID else { continue @@ -128,14 +131,9 @@ final class ChatDeliveryCoordinator { let currentStatus = chatMessages[index].deliveryStatus guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } - chatMessages[index].deliveryStatus = status - privateChats[peerID] = chatMessages - didUpdateStatus = true - didUpdatePrivateStatus = true - } - - if didUpdatePrivateStatus { - context.privateChats = privateChats + if context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID) { + didUpdateStatus = true + } } if didUpdateStatus { diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 43d29459..f13f057d 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -13,9 +13,14 @@ import Foundation protocol ChatLifecycleContext: AnyObject { // MARK: Chat & receipt state var messages: [BitchatMessage] { get } - var privateChats: [PeerID: [BitchatMessage]] { get set } - var unreadPrivateMessages: Set { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get } + /// Appends a private message via the single-writer store intent. + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + /// Clears the peer's unread flag (store unread state only). + func markPrivateChatRead(_ peerID: PeerID) var sentReadReceipts: Set { get } var nickname: String { get } var myPeerID: PeerID { get } @@ -33,7 +38,6 @@ protocol ChatLifecycleContext: AnyObject { /// Schedules main-actor work after a UI-timing delay. Injected so tests /// can run the work synchronously instead of polling wall-clock queues. func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) - func synchronizePrivateConversationStore() func addSystemMessage(_ content: String) // MARK: Peers & sessions @@ -72,8 +76,8 @@ extension ChatViewModel: ChatLifecycleContext { // `messages`, `privateChats`, `unreadPrivateMessages`, // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, - // `markPrivateMessagesAsRead(from:)`, - // `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`, + // `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`, + // `markPrivateChatRead(_:)`, `addSystemMessage(_:)`, // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, // the routing/ack members, `isTeleported`, // `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`, @@ -178,7 +182,6 @@ final class ChatLifecycleCoordinator { func markPrivateMessagesAsRead(from peerID: PeerID) { context.markChatAsRead(from: peerID) - context.synchronizePrivateConversationStore() if peerID.isGeoDM, let recipientHex = context.nostrKeyMapping[peerID], @@ -215,7 +218,7 @@ final class ChatLifecycleCoordinator { peerNostrPubkey = favoriteStatus?.peerNostrPublicKey if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { - context.unreadPrivateMessages.remove(noiseKeyHex) + context.markPrivateChatRead(noiseKeyHex) } } @@ -312,12 +315,7 @@ private extension ChatLifecycleCoordinator { senderPeerID: context.myPeerID ) - var chats = context.privateChats - if chats[peerID] == nil { - chats[peerID] = [] - } - chats[peerID]?.append(notice) - context.privateChats = chats + context.appendPrivateMessage(notice, to: peerID) } func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) { diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index fbabf1fb..54ce27ed 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -25,7 +25,10 @@ protocol ChatMediaTransferContext: AnyObject { func currentPublicSender() -> (name: String, peerID: PeerID) // MARK: Message state - var privateChats: [PeerID: [BitchatMessage]] { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + /// Appends a private message via the single-writer store intent. + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) func refreshVisibleMessages(from channel: ChannelID?) func trimMessagesIfNeeded() @@ -228,9 +231,7 @@ final class ChatMediaTransferCoordinator { senderPeerID: context.myPeerID, deliveryStatus: .sending ) - var chats = context.privateChats - chats[peerID, default: []].append(message) - context.privateChats = chats + context.appendPrivateMessage(message, to: peerID) context.trimMessagesIfNeeded() } else { let (displayName, senderPeerID) = context.currentPublicSender() diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 0d50ea90..2728afe3 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -17,8 +17,13 @@ import Foundation @MainActor protocol ChatPeerIdentityContext: AnyObject { // MARK: Conversation state - var privateChats: [PeerID: [BitchatMessage]] { get set } - var unreadPrivateMessages: Set { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + var unreadPrivateMessages: Set { get } + /// Clears the peer's unread flag (single-writer store intent). + func markPrivateChatRead(_ peerID: PeerID) + /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat + /// (dedup by ID, order preserved, unread carried, old chat removed). + func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) var selectedPrivateChatPeer: PeerID? { get set } var selectedPrivateChatFingerprint: String? { get set } var nickname: String { get } @@ -39,7 +44,6 @@ protocol ChatPeerIdentityContext: AnyObject { func syncReadReceiptsForSentMessages(for peerID: PeerID) /// Re-targets the private chat session in the chat manager (no store-sync side effects). func beginPrivateChatSession(with peerID: PeerID) - func synchronizePrivateConversationStore() func synchronizeConversationSelectionStore() func markPrivateMessagesAsRead(from peerID: PeerID) @@ -305,9 +309,7 @@ final class ChatPeerIdentityCoordinator { context.selectedPrivateChatPeer = currentPeerID } - var unread = context.unreadPrivateMessages - unread.remove(currentPeerID) - context.unreadPrivateMessages = unread + context.markPrivateChatRead(currentPeerID) } @MainActor @@ -367,7 +369,6 @@ final class ChatPeerIdentityCoordinator { context.selectedPrivateChatFingerprint = nil } context.beginPrivateChatSession(with: peerID) - context.synchronizePrivateConversationStore() context.synchronizeConversationSelectionStore() context.markPrivateMessagesAsRead(from: peerID) } @@ -553,30 +554,9 @@ private extension ChatPeerIdentityCoordinator { @MainActor func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) { - if let oldMessages = context.privateChats[oldPeerID] { - var chats = context.privateChats - chats[newPeerID, default: []].append(contentsOf: oldMessages) - chats[newPeerID]?.sort { $0.timestamp < $1.timestamp } - - var seenMessageIDs = Set() - chats[newPeerID] = chats[newPeerID]?.filter { message in - if seenMessageIDs.contains(message.id) { - return false - } - seenMessageIDs.insert(message.id) - return true - } - - chats.removeValue(forKey: oldPeerID) - context.privateChats = chats - } - - var unread = context.unreadPrivateMessages - if unread.contains(oldPeerID) { - unread.remove(oldPeerID) - unread.insert(newPeerID) - context.unreadPrivateMessages = unread - } + // The store migration dedups by message ID, preserves timestamp + // order, carries the unread flag, and removes the old chat. + context.migratePrivateChat(from: oldPeerID, to: newPeerID) } @MainActor diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 71145fe0..167401a6 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -14,7 +14,9 @@ protocol ChatPeerListContext: AnyObject { // MARK: Connection & chat state var isConnected: Bool { get set } var privateChats: [PeerID: [BitchatMessage]] { get } - var unreadPrivateMessages: Set { get set } + var unreadPrivateMessages: Set { get } + /// Clears the peer's unread flag (single-writer store intent). + func markPrivateChatRead(_ peerID: PeerID) var hasTrackedPrivateChatSelection: Bool { get } func updatePrivateChatPeerIfNeeded() func cleanupOldReadReceipts() @@ -155,7 +157,7 @@ private extension ChatPeerListCoordinator { } idsToRemove.append(staleID) - context.unreadPrivateMessages.remove(staleID) + context.markPrivateChatRead(staleID) } if !idsToRemove.isEmpty { diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 2ae4532c..401536de 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -14,13 +14,39 @@ import Foundation @MainActor protocol ChatPrivateConversationContext: AnyObject { // MARK: Conversation state - var privateChats: [PeerID: [BitchatMessage]] { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } var sentReadReceipts: Set { get } - var unreadPrivateMessages: Set { get set } + var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get } var nickname: String { get } var activeChannel: ChannelID { get } var nostrKeyMapping: [PeerID: String] { get } + + // MARK: Conversation store intents + // The sole mutation paths for private message state (single-writer + // `ConversationStore` ops; see docs/CONVERSATION-STORE-DESIGN.md). + /// Appends a private message in timestamp order; returns `false` on + /// duplicate message ID. + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + /// Replace-or-append a private message by ID, keeping its position. + func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) + /// Applies a delivery status by message ID; returns `false` when the + /// message is unknown or the update would downgrade the status. + @discardableResult + func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool + func markPrivateChatUnread(_ peerID: PeerID) + func markPrivateChatRead(_ peerID: PeerID) + /// Removes the peer's chat entirely, including unread state. + func removePrivateChat(_ peerID: PeerID) + /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat + /// (dedup by ID, order preserved, unread carried, old chat removed). + func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) + /// `true` when any private chat contains a message with `messageID`. + func privateChatsContainMessage(withID messageID: String) -> Bool + /// `true` when `peerID`'s chat contains a message with `messageID`. + func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool + /// Records that a read receipt is being sent for `messageID`. /// Returns `false` when one was already recorded — the caller must skip sending. @discardableResult @@ -65,10 +91,9 @@ protocol ChatPrivateConversationContext: AnyObject { func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) - // MARK: System messages & chat hygiene + // MARK: System messages func addSystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String) - func sanitizeChat(for peerID: PeerID) // MARK: Favorites & notifications /// The persisted favorite relationship for the peer's Noise static key, if any. @@ -165,10 +190,6 @@ extension ChatViewModel: ChatPrivateConversationContext { addSystemMessage(content, timestamp: Date()) } - func sanitizeChat(for peerID: PeerID) { - privateChatManager.sanitizeChat(for: peerID) - } - func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) } @@ -249,10 +270,7 @@ final class ChatPrivateConversationCoordinator { deliveryStatus: .sending ) - if context.privateChats[peerID] == nil { - context.privateChats[peerID] = [] - } - context.privateChats[peerID]?.append(message) + context.appendPrivateMessage(message, to: peerID) context.notifyUIChanged() if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { @@ -262,15 +280,15 @@ final class ChatPrivateConversationCoordinator { recipientNickname: recipientNickname ?? "user", messageID: messageID ) - if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[idx].deliveryStatus = .sent - } + context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID) } else { - if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[index].deliveryStatus = .failed( + context.setPrivateDeliveryStatus( + .failed( reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") - ) - } + ), + forMessageID: messageID, + peerID: peerID + ) let name = recipientNickname ?? "user" context.addSystemMessage( String( @@ -303,28 +321,28 @@ final class ChatPrivateConversationCoordinator { deliveryStatus: .sending ) - if context.privateChats[peerID] == nil { - context.privateChats[peerID] = [] - } - - context.privateChats[peerID]?.append(message) + context.appendPrivateMessage(message, to: peerID) context.notifyUIChanged() guard let recipientHex = context.nostrKeyMapping[peerID] else { - if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + context.setPrivateDeliveryStatus( + .failed( reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") - ) - } + ), + forMessageID: messageID, + peerID: peerID + ) return } if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) { - if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + context.setPrivateDeliveryStatus( + .failed( reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") - ) - } + ), + forMessageID: messageID, + peerID: peerID + ) context.addSystemMessage( String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") ) @@ -334,11 +352,13 @@ final class ChatPrivateConversationCoordinator { do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) if recipientHex.lowercased() == identity.publicKeyHex.lowercased() { - if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[idx].deliveryStatus = .failed( + context.setPrivateDeliveryStatus( + .failed( reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") - ) - } + ), + forMessageID: messageID, + peerID: peerID + ) return } @@ -352,15 +372,15 @@ final class ChatPrivateConversationCoordinator { from: identity, messageID: messageID ) - if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent - } + context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID) } catch { - if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[peerID]?[idx].deliveryStatus = .failed( + context.setPrivateDeliveryStatus( + .failed( reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") - ) - } + ), + forMessageID: messageID, + peerID: peerID + ) } } @@ -382,10 +402,7 @@ final class ChatPrivateConversationCoordinator { return } - if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } - for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) { - return - } + if context.privateChatsContainMessage(withID: messageId) { return } let senderName = context.displayNameForNostrPubkey(senderPubkey) let message = BitchatMessage( @@ -400,17 +417,14 @@ final class ChatPrivateConversationCoordinator { deliveryStatus: .delivered(to: context.nickname, at: Date()) ) - if context.privateChats[convKey] == nil { - context.privateChats[convKey] = [] - } - context.privateChats[convKey]?.append(message) + context.appendPrivateMessage(message, to: convKey) let isViewing = context.selectedPrivateChatPeer == convKey let wasReadBefore = context.sentReadReceipts.contains(messageId) let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage if shouldMarkUnread { - context.unreadPrivateMessages.insert(convKey) + context.markPrivateChatUnread(convKey) } if isViewing { @@ -427,10 +441,11 @@ final class ChatPrivateConversationCoordinator { func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[convKey]?[idx].deliveryStatus = .delivered( - to: context.displayNameForNostrPubkey(senderPubkey), - at: Date() + if context.privateChat(convKey, containsMessageWithID: messageID) { + context.setPrivateDeliveryStatus( + .delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()), + forMessageID: messageID, + peerID: convKey ) context.notifyUIChanged() SecureLogger.info( @@ -445,10 +460,11 @@ final class ChatPrivateConversationCoordinator { func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { - context.privateChats[convKey]?[idx].deliveryStatus = .read( - by: context.displayNameForNostrPubkey(senderPubkey), - at: Date() + if context.privateChat(convKey, containsMessageWithID: messageID) { + context.setPrivateDeliveryStatus( + .read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()), + forMessageID: messageID, + peerID: convKey ) context.notifyUIChanged() SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) @@ -575,17 +591,9 @@ final class ChatPrivateConversationCoordinator { if stableKeyHex != peerID, let nostrMessages = context.privateChats[stableKeyHex], !nostrMessages.isEmpty { - if context.privateChats[peerID] == nil { - context.privateChats[peerID] = [] - } - - let existingMessageIds = Set(context.privateChats[peerID]?.map { $0.id } ?? []) - for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) { - context.privateChats[peerID]?.append(nostrMessage) - } - - context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - context.privateChats.removeValue(forKey: stableKeyHex) + // Store migration dedups by ID, keeps timestamp order, and + // removes the stable-key chat. + context.migratePrivateChat(from: stableKeyHex, to: peerID) SecureLogger.info( "📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", @@ -612,33 +620,23 @@ final class ChatPrivateConversationCoordinator { context.sendMeshReadReceipt(receipt, to: peerID) context.markReadReceiptSent(message.id) } else { - context.unreadPrivateMessages.insert(peerID) + context.markPrivateChatUnread(peerID) context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID) } context.notifyUIChanged() } + /// O(1)-per-conversation dedup via the store's message-ID indexes + /// (replaces the full scan over every private chat). func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { - if context.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { - return true - } - for (_, messages) in context.privateChats where messages.contains(where: { $0.id == messageId }) { - return true - } - return false + context.privateChatsContainMessage(withID: messageId) } func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { - if context.privateChats[targetPeerID] == nil { - context.privateChats[targetPeerID] = [] - } - if let idx = context.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { - context.privateChats[targetPeerID]?[idx] = message - } else { - context.privateChats[targetPeerID]?.append(message) - } - context.sanitizeChat(for: targetPeerID) + // Store upsert replaces in place by message ID or inserts in + // timestamp order; the old per-append sanitize re-sort is obsolete. + context.upsertPrivateMessage(message, in: targetPeerID) } func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { @@ -649,15 +647,7 @@ final class ChatPrivateConversationCoordinator { return } - if context.privateChats[ephemeralPeerID] == nil { - context.privateChats[ephemeralPeerID] = [] - } - if let idx = context.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { - context.privateChats[ephemeralPeerID]?[idx] = message - } else { - context.privateChats[ephemeralPeerID]?.append(message) - } - context.sanitizeChat(for: ephemeralPeerID) + context.upsertPrivateMessage(message, in: ephemeralPeerID) } func handleViewingThisChat( @@ -666,10 +656,10 @@ final class ChatPrivateConversationCoordinator { key: Data?, senderPubkey: String ) { - context.unreadPrivateMessages.remove(targetPeerID) + context.markPrivateChatRead(targetPeerID) if let key, let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) { - context.unreadPrivateMessages.remove(ephemeralPeerID) + context.markPrivateChatRead(ephemeralPeerID) } guard !context.sentReadReceipts.contains(message.id) else { return } @@ -702,11 +692,11 @@ final class ChatPrivateConversationCoordinator { ) { guard shouldMarkAsUnread else { return } - context.unreadPrivateMessages.insert(targetPeerID) + context.markPrivateChatUnread(targetPeerID) if let key, let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key), ephemeralPeerID != targetPeerID { - context.unreadPrivateMessages.insert(ephemeralPeerID) + context.markPrivateChatUnread(ephemeralPeerID) } if isRecentMessage { context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID) @@ -778,8 +768,13 @@ final class ChatPrivateConversationCoordinator { let currentFingerprint = context.getFingerprint(for: peerID) if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true { - var migratedMessages: [BitchatMessage] = [] + // 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 + // via ordered append (dedup by ID) instead. + var partiallyMigratedMessages: [BitchatMessage] = [] var oldPeerIDsToRemove: [PeerID] = [] + var didMigrate = false let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID { @@ -790,10 +785,11 @@ final class ChatPrivateConversationCoordinator { if let currentFp = currentFingerprint, let oldFp = oldFingerprint, currentFp == oldFp { - migratedMessages.append(contentsOf: recentMessages) + didMigrate = true if recentMessages.count == messages.count { oldPeerIDsToRemove.append(oldPeerID) } else { + partiallyMigratedMessages.append(contentsOf: recentMessages) SecureLogger.info( "📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session @@ -811,9 +807,11 @@ final class ChatPrivateConversationCoordinator { } if isRelevantChat { - migratedMessages.append(contentsOf: recentMessages) + didMigrate = true if recentMessages.count == messages.count { oldPeerIDsToRemove.append(oldPeerID) + } else { + partiallyMigratedMessages.append(contentsOf: recentMessages) } SecureLogger.warning( @@ -826,21 +824,22 @@ final class ChatPrivateConversationCoordinator { if !oldPeerIDsToRemove.isEmpty { for oldID in oldPeerIDsToRemove { - context.privateChats.removeValue(forKey: oldID) - context.unreadPrivateMessages.remove(oldID) + // The old behavior dropped the unread flag of removed + // chats instead of transferring it; clear it before the + // migration so the store doesn't carry it over. + context.markPrivateChatRead(oldID) + context.migratePrivateChat(from: oldID, to: peerID) context.clearStoredFingerprint(for: oldID) } context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID) } - if !migratedMessages.isEmpty { - if context.privateChats[peerID] == nil { - context.privateChats[peerID] = [] - } - context.privateChats[peerID]?.append(contentsOf: migratedMessages) - context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - context.sanitizeChat(for: peerID) + for message in partiallyMigratedMessages { + context.appendPrivateMessage(message, to: peerID) + } + + if didMigrate { context.notifyUIChanged() } } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 41ae4ffd..2cb1ddd9 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -48,12 +48,17 @@ protocol ChatPublicConversationContext: AnyObject { func setConversationActiveChannel(_ channel: ChannelID) func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) - func synchronizePrivateConversationStore() func synchronizeConversationSelectionStore() // MARK: Private chats (block cleanup & message removal) - var privateChats: [PeerID: [BitchatMessage]] { get set } - var unreadPrivateMessages: Set { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + /// Removes the peer's chat entirely, including unread state + /// (single-writer store intent). + func removePrivateChat(_ peerID: PeerID) + /// Removes a message by ID from every private chat containing it, + /// dropping chats that become empty. Returns the removed message. + @discardableResult + func removePrivateMessage(withID messageID: String) -> BitchatMessage? func cleanupLocalFile(forMessage message: BitchatMessage) // MARK: Geohash participants & presence @@ -251,13 +256,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { let conversationPeerID = PeerID(nostr_: hex) if context.privateChats[conversationPeerID] != nil { - var privateChats = context.privateChats - privateChats.removeValue(forKey: conversationPeerID) - context.privateChats = privateChats - - var unread = context.unreadPrivateMessages - unread.remove(conversationPeerID) - context.unreadPrivateMessages = unread + context.removePrivateChat(conversationPeerID) } context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) @@ -325,21 +324,9 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { synchronizeAllPublicConversationStores() } - var chats = context.privateChats - for (peerID, items) in chats { - let filtered = items.filter { $0.id != messageID } - if filtered.count != items.count { - if filtered.isEmpty { - chats.removeValue(forKey: peerID) - } else { - chats[peerID] = filtered - } - if removedMessage == nil { - removedMessage = items.first(where: { $0.id == messageID }) - } - } + if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) { + removedMessage = removedMessage ?? removedPrivateMessage } - context.privateChats = chats if cleanupFile, let removedMessage { context.cleanupLocalFile(forMessage: removedMessage) @@ -351,7 +338,6 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func initializeConversationStore() { context.setConversationActiveChannel(context.activeChannel) synchronizePublicConversationStore(for: context.activeChannel) - context.synchronizePrivateConversationStore() context.synchronizeConversationSelectionStore() } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index ee19a527..33e46b93 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -15,9 +15,17 @@ protocol ChatTransportEventContext: AnyObject { var isConnected: Bool { get set } var nickname: String { get } var myPeerID: PeerID { get } - var privateChats: [PeerID: [BitchatMessage]] { get set } - var unreadPrivateMessages: Set { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + var unreadPrivateMessages: Set { get } var selectedPrivateChatPeer: PeerID? { get set } + /// Appends a private message via the single-writer store intent; + /// returns `false` on duplicate message ID. + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + /// Removes the peer's chat entirely, including unread state. + func removePrivateChat(_ peerID: PeerID) + func markPrivateChatUnread(_ peerID: PeerID) + func markPrivateChatRead(_ peerID: PeerID) /// Forgets that read receipts were sent for `ids` so READ acks can be /// re-sent after the peer reconnects. (Single mutation path for the /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) @@ -251,13 +259,12 @@ private extension ChatTransportEventCoordinator { to stablePeerID: PeerID, in context: any ChatTransportEventContext ) { - if let messages = context.privateChats[shortPeerID] { - if context.privateChats[stablePeerID] == nil { - context.privateChats[stablePeerID] = [] - } + let hadUnread = context.unreadPrivateMessages.contains(shortPeerID) - let existingIDs = Set(context.privateChats[stablePeerID]?.map(\.id) ?? []) - for message in messages where !existingIDs.contains(message.id) { + if let messages = context.privateChats[shortPeerID] { + for message in messages { + // Rewrite senderPeerID to the stable key so read receipts + // keep working; store append dedups by ID and keeps order. let migrated = BitchatMessage( id: message.id, sender: message.sender, @@ -273,16 +280,15 @@ private extension ChatTransportEventCoordinator { mentions: message.mentions, deliveryStatus: message.deliveryStatus ) - context.privateChats[stablePeerID]?.append(migrated) + context.appendPrivateMessage(migrated, to: stablePeerID) } - context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp } - context.privateChats.removeValue(forKey: shortPeerID) + context.removePrivateChat(shortPeerID) } - if context.unreadPrivateMessages.contains(shortPeerID) { - context.unreadPrivateMessages.remove(shortPeerID) - context.unreadPrivateMessages.insert(stablePeerID) + if hadUnread { + context.markPrivateChatRead(shortPeerID) + context.markPrivateChatUnread(stablePeerID) } context.selectedPrivateChatPeer = stablePeerID diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index fbd3daab..948514db 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -164,13 +164,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor 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. + @MainActor var privateChats: [PeerID: [BitchatMessage]] { - get { privateChatManager.privateChats } - set { - privateChatManager.privateChats = newValue - schedulePrivateConversationStoreSynchronization() - } + conversations.directMessagesByRoutingPeerID() } + @MainActor var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } set { @@ -179,19 +185,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } else { privateChatManager.endChat() } - synchronizePrivateConversationStore() synchronizeConversationSelectionStore() } } + /// Read-only compat view of the store's unread direct conversations + /// (migration step 2 shim). Mutate via `markPrivateChatUnread(_:)` / + /// `markPrivateChatRead(_:)`. + @MainActor var unreadPrivateMessages: Set { - get { privateChatManager.unreadMessages } - set { - privateChatManager.unreadMessages = newValue - schedulePrivateConversationStoreSynchronization() - } + conversations.unreadDirectRoutingPeerIDs() } /// Check if there are any unread messages (including from temporary Nostr peer IDs) + @MainActor var hasAnyUnreadMessages: Bool { !unreadPrivateMessages.isEmpty } @@ -271,6 +277,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol let conversationStore: LegacyConversationStore + /// Single source of truth for conversation message state + /// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed + /// through, mirroring the legacy store's wiring. + 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 @@ -374,7 +387,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Message Delivery Tracking var cancellables = Set() - private var pendingPrivateConversationStoreSyncTask: Task? var transferIdToMessageIDs: [String: [String]] { mediaTransferCoordinator.transferIdToMessageIDs @@ -525,6 +537,100 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele selectedPrivateChatPeer = newPeerID } + // MARK: - Private Conversation Store Intents + // 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. + + /// 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 + /// conversation's ID index). + @MainActor + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { + conversations.append(message, to: .directPeer(peerID)) + } + + /// Replace-or-append a private message by ID (media progress, mirrored + /// copies); an existing message keeps its timeline position. + @MainActor + func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { + conversations.upsertByID(message, in: .directPeer(peerID)) + } + + /// Applies a delivery status to a private message by ID. Returns `false` + /// when the message is unknown or the update would downgrade the status + /// (read beats delivered beats sent). + @MainActor + @discardableResult + func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { + conversations.setDeliveryStatus(status, forMessageID: messageID, in: .directPeer(peerID)) + } + + /// Flags the peer's chat as unread (store unread state). + @MainActor + func markPrivateChatUnread(_ peerID: PeerID) { + conversations.markUnread(.directPeer(peerID)) + } + + /// Clears the peer's unread flag (store unread state only; read-receipt + /// sending stays in `PrivateChatManager.markAsRead`). + @MainActor + func markPrivateChatRead(_ peerID: PeerID) { + conversations.markRead(.directPeer(peerID)) + } + + /// Empties the peer's chat but keeps the conversation alive (`/clear`). + @MainActor + func clearPrivateChat(_ peerID: PeerID) { + conversations.clear(.directPeer(peerID)) + } + + /// Removes the peer's chat entirely, including unread state. + @MainActor + func removePrivateChat(_ peerID: PeerID) { + conversations.removeConversation(.directPeer(peerID)) + } + + /// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat + /// (ephemeral↔stable peer-ID handoff): dedups by ID, preserves order, + /// carries unread state, removes the old chat. + @MainActor + func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { + conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID)) + } + + /// `true` when any private chat contains a message with `messageID` + /// (O(1) per conversation via the store's ID indexes). + @MainActor + func privateChatsContainMessage(withID messageID: String) -> Bool { + conversations.directConversationsContainMessage(withID: messageID) + } + + /// `true` when `peerID`'s chat contains a message with `messageID`. + @MainActor + func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool { + conversations.conversationsByID[.directPeer(peerID)]?.containsMessage(withID: messageID) ?? false + } + + /// Removes a message by ID from every private chat that contains it, + /// dropping chats that become empty. Returns the removed message, if any. + @MainActor + @discardableResult + func removePrivateMessage(withID messageID: String) -> BitchatMessage? { + var removed: BitchatMessage? + for (id, conversation) in conversations.conversationsByID { + guard case .direct = id, conversation.containsMessage(withID: messageID) else { continue } + let message = conversations.removeMessage(withID: messageID, from: id) + removed = removed ?? message + if conversation.messages.isEmpty { + conversations.removeConversation(id) + } + } + return removed + } + // MARK: - Initialization @MainActor @@ -533,6 +639,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, conversationStore: LegacyConversationStore? = nil, + conversations: ConversationStore? = nil, identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, @@ -546,6 +653,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele identityManager: identityManager, transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), conversationStore: conversationStore, + conversations: conversations, identityResolver: identityResolver, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), @@ -562,12 +670,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele 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() @@ -582,6 +692,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.idBridge = idBridge self.identityManager = identityManager self.conversationStore = conversationStore + self.conversations = conversations self.identityResolver = identityResolver self.peerIdentityStore = peerIdentityStore self.locationPresenceStore = locationPresenceStore @@ -596,6 +707,25 @@ 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 (their debounced + // Legacy synchronization sinks are gone; the bridge above feeds + // Legacy instead). + conversations.changes + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + ChatViewModelBootstrapper(viewModel: self).configure() initializeConversationStore() } @@ -789,8 +919,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele recipientNickname: meshService.peerNickname(peerID: peerID), senderPeerID: meshService.myPeerID ) - if privateChats[peerID] == nil { privateChats[peerID] = [] } - privateChats[peerID]?.append(systemMessage) + appendPrivateMessage(systemMessage, to: peerID) objectWillChange.send() } @@ -885,8 +1014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele meshCap: TransportConfig.meshTimelineCap, geohashCap: TransportConfig.geoTimelineCap ) - privateChatManager.privateChats.removeAll() - privateChatManager.unreadMessages.removeAll() + conversations.removeAllDirectConversations() // Delete all keychain data (including Noise and Nostr keys) _ = keychain.deleteAllKeychainData() @@ -1080,24 +1208,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.synchronizeAllPublicConversationStores() } + /// 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 schedulePrivateConversationStoreSynchronization() { - guard pendingPrivateConversationStoreSyncTask == nil else { return } - pendingPrivateConversationStoreSyncTask = Task { @MainActor [weak self] in - await Task.yield() - guard let self else { return } - self.pendingPrivateConversationStoreSyncTask = nil - self.synchronizePrivateConversationStore() - } - } - - @MainActor - func synchronizePrivateConversationStore() { - conversationStore.synchronizePrivateChats( - privateChatManager.privateChats, - unreadPeerIDs: privateChatManager.unreadMessages, - identityResolver: identityResolver - ) + func resynchronizeLegacyPrivateConversations() { + legacyStoreBridge?.resynchronizeAll() } @MainActor diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 3fec30e0..5e1b71d4 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -74,6 +74,7 @@ final class ChatViewModelBootstrapper { private extension ChatViewModelBootstrapper { func wireServiceGraph() { + viewModel.privateChatManager.conversationStore = viewModel.conversations viewModel.privateChatManager.messageRouter = viewModel.messageRouter viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter @@ -89,24 +90,10 @@ private extension ChatViewModelBootstrapper { } .store(in: &viewModel.cancellables) - viewModel.privateChatManager.$privateChats - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] _ in - Task { @MainActor [weak viewModel] in - viewModel?.schedulePrivateConversationStoreSynchronization() - } - } - .store(in: &viewModel.cancellables) - - viewModel.privateChatManager.$unreadMessages - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] _ in - Task { @MainActor [weak viewModel] in - viewModel?.schedulePrivateConversationStoreSynchronization() - } - } - .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. viewModel.privateChatManager.$selectedPeer .receive(on: DispatchQueue.main) .sink { [weak viewModel] _ in @@ -192,7 +179,9 @@ private extension ChatViewModelBootstrapper { viewModel.updatePrivateChatPeerIfNeeded() } - viewModel.synchronizePrivateConversationStore() + // Peer registrations can change a conversation's + // canonical handle in the legacy store; re-key it. + viewModel.resynchronizeLegacyPrivateConversations() viewModel.synchronizeConversationSelectionStore() } } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 975d2ec3..095e864d 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -631,7 +631,7 @@ struct AppArchitectureTests { transport.reachablePeers.insert(otherPeerID) viewModel.nickname = "builder" viewModel.verifiedFingerprints.insert(verifiedFingerprint) - viewModel.unreadPrivateMessages = Set([otherPeerID]) + viewModel.markPrivateChatUnread(otherPeerID) transport.updatePeerSnapshots([ makeArchitectureSnapshot( peerID: myPeerID, diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 45303bbc..8e5d2a88 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -38,9 +38,23 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { var nostrKeyMapping: [PeerID: String] = [:] private(set) var ownerLevelReadPasses: [PeerID] = [] private(set) var managerReadMarks: [PeerID] = [] - private(set) var privateStoreSyncCount = 0 private(set) var systemMessages: [String] = [] + // Conversation store intents + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { + var chat = privateChats[peerID] ?? [] + guard !chat.contains(where: { $0.id == message.id }) else { return false } + let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count + chat.insert(message, at: index) + privateChats[peerID] = chat + return true + } + + func markPrivateChatRead(_ peerID: PeerID) { + unreadPrivateMessages.remove(peerID) + } + @discardableResult func markReadReceiptSent(_ messageID: String) -> Bool { sentReadReceipts.insert(messageID).inserted @@ -61,7 +75,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { work() } - func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } func addSystemMessage(_ content: String) { systemMessages.append(content) } // Peers & sessions @@ -234,7 +247,6 @@ struct ChatLifecycleCoordinatorContextTests { coordinator.markPrivateMessagesAsRead(from: convKey) #expect(context.managerReadMarks == [convKey]) - #expect(context.privateStoreSyncCount == 1) // Only the peer's own un-acked, non-relay message gets a READ. #expect(context.geoReadReceipts.map(\.messageID) == ["m1"]) #expect(context.geoReadReceipts.first?.recipientHex == recipientHex) diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 12e639a1..32cf8276 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -42,6 +42,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { // Message state var privateChats: [PeerID: [BitchatMessage]] = [:] + + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { + var chat = privateChats[peerID] ?? [] + guard !chat.contains(where: { $0.id == message.id }) else { return false } + chat.append(message) + privateChats[peerID] = chat + return true + } + var meshTimeline: [BitchatMessage] = [] private(set) var refreshedChannels: [ChannelID?] = [] private(set) var trimCount = 0 diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index e162b77d..ebceeb30 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -38,11 +38,34 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { func notifyUIChanged() { notifyUIChangedCount += 1 } func addSystemMessage(_ content: String) { systemMessages.append(content) } + // Conversation store intents (mirror `ConversationStore` migrate + // semantics: dedup by ID, timestamp order, unread carried, old chat + // removed) while recording calls for assertions. + private(set) var migratedChats: [(from: PeerID, to: PeerID)] = [] + + func markPrivateChatRead(_ peerID: PeerID) { + unreadPrivateMessages.remove(peerID) + } + + func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { + migratedChats.append((oldPeerID, newPeerID)) + guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return } + var destination = privateChats[newPeerID] ?? [] + for message in source where !destination.contains(where: { $0.id == message.id }) { + let index = destination.firstIndex(where: { $0.timestamp > message.timestamp }) ?? destination.count + destination.insert(message, at: index) + } + privateChats[newPeerID] = destination + privateChats.removeValue(forKey: oldPeerID) + if unreadPrivateMessages.remove(oldPeerID) != nil { + unreadPrivateMessages.insert(newPeerID) + } + } + // Private chat session lifecycle private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] private(set) var syncedReadReceiptPeers: [PeerID] = [] private(set) var begunChatSessions: [PeerID] = [] - private(set) var privateStoreSyncCount = 0 private(set) var selectionStoreSyncCount = 0 private(set) var markedReadPeers: [PeerID] = [] @@ -60,7 +83,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { begunChatSessions.append(peerID) } - func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 } func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } @@ -261,7 +283,6 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) #expect(context.selectedPrivateChatFingerprint == "fp-alice") #expect(context.begunChatSessions == [peerID]) - #expect(context.privateStoreSyncCount == 1) #expect(context.selectionStoreSyncCount == 1) #expect(context.markedReadPeers == [peerID]) diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 67e433f5..8a81611c 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -32,6 +32,10 @@ private final class MockChatPeerListContext: ChatPeerListContext { private(set) var updatePrivateChatPeerIfNeededCount = 0 private(set) var cleanupOldReadReceiptsCount = 0 + func markPrivateChatRead(_ peerID: PeerID) { + unreadPrivateMessages.remove(peerID) + } + func updatePrivateChatPeerIfNeeded() { updatePrivateChatPeerIfNeededCount += 1 } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 7a6fcf67..2a19afe9 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -48,6 +48,90 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC notifyUIChangedCount += 1 } + // Conversation store intents (mirror `ConversationStore` semantics: + // ordered insert, dedup by ID, no-downgrade status, unread carry on + // migrate) while recording calls for assertions. + private(set) var upsertedMessages: [(messageID: String, peerID: PeerID)] = [] + private(set) var migratedChats: [(from: PeerID, to: PeerID)] = [] + + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { + var chat = privateChats[peerID] ?? [] + guard !chat.contains(where: { $0.id == message.id }) else { + privateChats[peerID] = chat + return false + } + let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count + chat.insert(message, at: index) + privateChats[peerID] = chat + return true + } + + func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { + upsertedMessages.append((message.id, peerID)) + if var chat = privateChats[peerID], + let index = chat.firstIndex(where: { $0.id == message.id }) { + chat[index] = message + privateChats[peerID] = chat + } else { + appendPrivateMessage(message, to: peerID) + } + } + + @discardableResult + func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { + guard var chat = privateChats[peerID], + let index = chat.firstIndex(where: { $0.id == messageID }) else { + return false + } + if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) { + return false + } + chat[index].deliveryStatus = status + privateChats[peerID] = chat + return true + } + + func markPrivateChatUnread(_ peerID: PeerID) { + unreadPrivateMessages.insert(peerID) + } + + func markPrivateChatRead(_ peerID: PeerID) { + unreadPrivateMessages.remove(peerID) + } + + func removePrivateChat(_ peerID: PeerID) { + privateChats.removeValue(forKey: peerID) + unreadPrivateMessages.remove(peerID) + } + + func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) { + migratedChats.append((oldPeerID, newPeerID)) + guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return } + for message in source { + appendPrivateMessage(message, to: newPeerID) + } + if privateChats[newPeerID] == nil { + privateChats[newPeerID] = [] + } + let wasUnread = unreadPrivateMessages.contains(oldPeerID) + privateChats.removeValue(forKey: oldPeerID) + unreadPrivateMessages.remove(oldPeerID) + if wasUnread { + unreadPrivateMessages.insert(newPeerID) + } + } + + func privateChatsContainMessage(withID messageID: String) -> Bool { + privateChats.values.contains { chat in + chat.contains { $0.id == messageID } + } + } + + func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool { + privateChats[peerID]?.contains { $0.id == messageID } == true + } + // Peers & identity var myPeerID = PeerID(str: "0011223344556677") var nicknamesByPeerID: [PeerID: String] = [:] @@ -149,10 +233,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC privateMessageNotifications.append((senderName, message, peerID)) } - // System messages & chat hygiene + // System messages private(set) var systemMessages: [String] = [] private(set) var meshOnlySystemMessages: [String] = [] - private(set) var sanitizedPeerIDs: [PeerID] = [] func addSystemMessage(_ content: String) { systemMessages.append(content) @@ -162,10 +245,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC meshOnlySystemMessages.append(content) } - func sanitizeChat(for peerID: PeerID) { - sanitizedPeerIDs.append(peerID) - } - static let dummyIdentity = NostrIdentity( privateKey: Data(repeating: 0x11, count: 32), publicKey: Data(repeating: 0x22, count: 32), @@ -256,7 +335,9 @@ struct ChatPrivateConversationCoordinatorContextTests { // A different id appends. coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID) #expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"]) - #expect(context.sanitizedPeerIDs == [peerID, peerID, peerID]) + // Every add went through the store's upsert intent. + #expect(context.upsertedMessages.map(\.peerID) == [peerID, peerID, peerID]) + #expect(context.upsertedMessages.map(\.messageID) == ["m1", "m1", "m2"]) #expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID)) #expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID)) @@ -436,7 +517,9 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.unreadPrivateMessages.isEmpty) #expect(context.clearedFingerprints == [oldPeerID]) #expect(context.selectedPrivateChatPeer == newPeerID) - #expect(context.sanitizedPeerIDs == [newPeerID]) + // The wholesale move went through the store's migrate intent. + #expect(context.migratedChats.map(\.from) == [oldPeerID]) + #expect(context.migratedChats.map(\.to) == [newPeerID]) #expect(context.notifyUIChangedCount == 1) } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index fb661241..55b40079 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -111,7 +111,6 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon private(set) var conversationActiveChannels: [ChannelID] = [] private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = [] private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = [] - private(set) var privateStoreSyncCount = 0 private(set) var selectionStoreSyncCount = 0 func setConversationActiveChannel(_ channel: ChannelID) { @@ -126,10 +125,6 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon replacedConversationMessages.append((conversationID, messages.map(\.id))) } - func synchronizePrivateConversationStore() { - privateStoreSyncCount += 1 - } - func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 } @@ -137,8 +132,31 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon // Private chats var privateChats: [PeerID: [BitchatMessage]] = [:] var unreadPrivateMessages: Set = [] + private(set) var removedPrivateChats: [PeerID] = [] private(set) var cleanedUpFileMessageIDs: [String] = [] + func removePrivateChat(_ peerID: PeerID) { + removedPrivateChats.append(peerID) + privateChats.removeValue(forKey: peerID) + unreadPrivateMessages.remove(peerID) + } + + @discardableResult + func removePrivateMessage(withID messageID: String) -> BitchatMessage? { + var removed: BitchatMessage? + for (peerID, chat) in privateChats { + guard let message = chat.first(where: { $0.id == messageID }) else { continue } + removed = removed ?? message + let remaining = chat.filter { $0.id != messageID } + if remaining.isEmpty { + privateChats.removeValue(forKey: peerID) + } else { + privateChats[peerID] = remaining + } + } + return removed + } + func cleanupLocalFile(forMessage message: BitchatMessage) { cleanedUpFileMessageIDs.append(message.id) } diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 9b6ec7ce..e9de3901 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -33,6 +33,30 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { private(set) var unmarkedReadReceiptBatches: [[String]] = [] private(set) var notifyUIChangedCount = 0 + // Conversation store intents (mirror `ConversationStore` semantics) + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool { + var chat = privateChats[peerID] ?? [] + guard !chat.contains(where: { $0.id == message.id }) else { return false } + let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count + chat.insert(message, at: index) + privateChats[peerID] = chat + return true + } + + func removePrivateChat(_ peerID: PeerID) { + privateChats.removeValue(forKey: peerID) + unreadPrivateMessages.remove(peerID) + } + + func markPrivateChatUnread(_ peerID: PeerID) { + unreadPrivateMessages.insert(peerID) + } + + func markPrivateChatRead(_ peerID: PeerID) { + unreadPrivateMessages.remove(peerID) + } + func unmarkReadReceiptsSent(_ ids: [String]) { unmarkedReadReceiptBatches.append(ids) } diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 9730c053..d176b249 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -54,7 +54,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .read(by: "Peer", at: Date()) ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) // Action: try to downgrade to .delivered viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) @@ -85,7 +85,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) // Action: upgrade to .delivered viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date())) @@ -114,7 +114,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent) @@ -140,7 +140,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60)) ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) // Action: upgrade to .read viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date())) @@ -173,7 +173,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) // Action: receive read receipt let receipt = ReadReceipt( @@ -207,7 +207,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"] viewModel.isStartupPhase = false @@ -265,7 +265,7 @@ struct ChatViewModelDeliveryStatusTests { deliveryStatus: .sent ) ] - viewModel.privateChats[firstPeerID] = [ + viewModel.seedPrivateChat([ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -277,8 +277,8 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - ] - viewModel.privateChats[secondPeerID] = [ + ], for: firstPeerID) + viewModel.seedPrivateChat([ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -290,7 +290,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - ] + ], for: secondPeerID) let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( messageID, @@ -331,10 +331,15 @@ struct ChatViewModelDeliveryStatusTests { deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [targetMessage, olderMessage] + viewModel.seedPrivateChat([targetMessage], for: peerID) #expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID))) - viewModel.privateChats[peerID] = [olderMessage, targetMessage] + // A late arrival with an older timestamp is inserted before the + // target by the store, shifting its position and invalidating the + // delivery coordinator's location index. + viewModel.seedPrivateChat([olderMessage], for: peerID) + #expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID]) + let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( messageID, status: .read(by: "Peer", at: Date()) @@ -401,6 +406,20 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { func markMessageDelivered(_ messageID: String) { markedDeliveredMessageIDs.append(messageID) } + + @discardableResult + func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { + guard var chat = privateChats[peerID], + let index = chat.firstIndex(where: { $0.id == messageID }) else { + return false + } + if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) { + return false + } + chat[index].deliveryStatus = status + privateChats[peerID] = chat + return true + } } @MainActor diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index b275358c..eb136cfa 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -177,7 +177,7 @@ struct ChatViewModelPrivateChatExtensionTests { isPrivate: true, senderPeerID: oldPeerID ) - viewModel.privateChats[oldPeerID] = [oldMessage] + viewModel.seedPrivateChat([oldMessage], for: oldPeerID) viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint // Setup new peer fingerprint @@ -444,7 +444,7 @@ struct ChatViewModelNostrExtensionTests { let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "geo-ack-delivered" - viewModel.privateChats[convKey] = [ + viewModel.seedPrivateChat([ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -456,7 +456,7 @@ struct ChatViewModelNostrExtensionTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) - ] + ], for: convKey) let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( @@ -482,7 +482,7 @@ struct ChatViewModelNostrExtensionTests { let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "geo-ack-read" - viewModel.privateChats[convKey] = [ + viewModel.seedPrivateChat([ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -494,7 +494,7 @@ struct ChatViewModelNostrExtensionTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .delivered(to: "Friend", at: Date()) ) - ] + ], for: convKey) let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( @@ -578,7 +578,7 @@ struct ChatViewModelNostrExtensionTests { let convKey = PeerID(nostr_: sender.publicKeyHex) let messageID = "gift-delivered" - viewModel.privateChats[convKey] = [ + viewModel.seedPrivateChat([ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -590,7 +590,7 @@ struct ChatViewModelNostrExtensionTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) - ] + ], for: convKey) let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let giftWrap = try NostrProtocol.createPrivateMessage( @@ -1095,7 +1095,7 @@ struct ChatViewModelMediaTransferTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sending ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id) viewModel.cancelMediaSend(messageID: message.id) @@ -1124,7 +1124,7 @@ struct ChatViewModelMediaTransferTests { senderPeerID: viewModel.meshService.myPeerID, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id) viewModel.deleteMediaMessage(messageID: message.id) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 304b96d5..7b442ed5 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -136,7 +136,7 @@ struct ChatViewModelIdentityTests { senderPeerID: oldPeerID, mentions: nil ) - viewModel.privateChats[oldPeerID] = [existingMessage] + viewModel.seedPrivateChat([existingMessage], for: oldPeerID) viewModel.startPrivateChat(with: oldPeerID) #expect(viewModel.selectedPrivateChatPeer == oldPeerID) @@ -345,8 +345,8 @@ struct ChatViewModelServiceLifecycleTests { mentions: nil ) - viewModel.privateChats[peerID] = [message] - viewModel.unreadPrivateMessages.insert(peerID) + viewModel.seedPrivateChat([message], for: peerID) + viewModel.markPrivateChatUnread(peerID) viewModel.selectedPrivateChatPeer = peerID viewModel.handleDidBecomeActive() @@ -497,7 +497,7 @@ struct ChatViewModelNoisePayloadTests { mentions: nil, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) viewModel.didReceiveNoisePayload( from: peerID, @@ -535,7 +535,7 @@ struct ChatViewModelNoisePayloadTests { mentions: nil, deliveryStatus: .sent ) - viewModel.privateChats[peerID] = [message] + viewModel.seedPrivateChat([message], for: peerID) viewModel.didReceiveNoisePayload( from: peerID, @@ -771,7 +771,7 @@ struct ChatViewModelPeerTests { func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async { let (viewModel, _) = makeTestableViewModel() let stalePeer = PeerID(str: "00000000000000a2") - viewModel.unreadPrivateMessages = [stalePeer] + viewModel.markPrivateChatUnread(stalePeer) viewModel.didUpdatePeerList([]) @@ -798,8 +798,8 @@ struct ChatViewModelPeerTests { senderPeerID: stablePeer, mentions: nil ) - viewModel.privateChats[stablePeer] = [message] - viewModel.unreadPrivateMessages = [stablePeer] + viewModel.seedPrivateChat([message], for: stablePeer) + viewModel.markPrivateChatUnread(stablePeer) viewModel.didUpdatePeerList([]) try? await Task.sleep(nanoseconds: 100_000_000) @@ -876,33 +876,32 @@ struct ChatViewModelPrivateChatSelectionTests { let older = Date().addingTimeInterval(-120) let newer = Date().addingTimeInterval(-30) - viewModel.privateChats = [ - peerA: [ - BitchatMessage( - id: "a-1", - sender: "A", - content: "Old", - timestamp: older, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerA - ) - ], - peerB: [ - BitchatMessage( - id: "b-1", - sender: "B", - content: "New", - timestamp: newer, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerB - ) - ] - ] - viewModel.unreadPrivateMessages = [peerA, peerB] + viewModel.seedPrivateChat([ + BitchatMessage( + id: "a-1", + sender: "A", + content: "Old", + timestamp: older, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerA + ) + ], for: peerA) + viewModel.seedPrivateChat([ + BitchatMessage( + id: "b-1", + sender: "B", + content: "New", + timestamp: newer, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerB + ) + ], for: peerB) + viewModel.markPrivateChatUnread(peerA) + viewModel.markPrivateChatUnread(peerB) viewModel.openMostRelevantPrivateChat() @@ -918,32 +917,30 @@ struct ChatViewModelPrivateChatSelectionTests { let older = Date().addingTimeInterval(-200) let newer = Date().addingTimeInterval(-20) - viewModel.privateChats = [ - peerA: [ - BitchatMessage( - id: "a-1", - sender: "A", - content: "Old", - timestamp: older, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerA - ) - ], - peerB: [ - BitchatMessage( - id: "b-1", - sender: "B", - content: "New", - timestamp: newer, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerB - ) - ] - ] + viewModel.seedPrivateChat([ + BitchatMessage( + id: "a-1", + sender: "A", + content: "Old", + timestamp: older, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerA + ) + ], for: peerA) + viewModel.seedPrivateChat([ + BitchatMessage( + id: "b-1", + sender: "B", + content: "New", + timestamp: newer, + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: peerB + ) + ], for: peerB) viewModel.openMostRelevantPrivateChat() @@ -1011,7 +1008,7 @@ struct ChatViewModelPanicTests { isRelay: false ) ] - viewModel.privateChats[PeerID(str: "PEER1")] = [ + viewModel.seedPrivateChat([ BitchatMessage( id: "pm-1", sender: "Peer", @@ -1022,8 +1019,8 @@ struct ChatViewModelPanicTests { recipientNickname: "Me", senderPeerID: PeerID(str: "PEER1") ) - ] - viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1")) + ], for: PeerID(str: "PEER1")) + viewModel.markPrivateChatUnread(PeerID(str: "PEER1")) viewModel.panicClearAllData() diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 214d675f..641fdfdd 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -423,6 +423,12 @@ private final class MockCommandContextProvider: CommandContextProvider { clearCurrentPublicTimelineCallCount += 1 } + private(set) var clearedPrivateChats: [PeerID] = [] + func clearPrivateChat(_ peerID: PeerID) { + clearedPrivateChats.append(peerID) + privateChats[peerID] = [] + } + func sendPublicRaw(_ content: String) { sentPublicRawMessages.append(content) } diff --git a/bitchatTests/LegacyConversationStoreBridgeTests.swift b/bitchatTests/LegacyConversationStoreBridgeTests.swift new file mode 100644 index 00000000..cf1b6ab7 --- /dev/null +++ b/bitchatTests/LegacyConversationStoreBridgeTests.swift @@ -0,0 +1,177 @@ +// +// LegacyConversationStoreBridgeTests.swift +// bitchatTests +// +// Focused tests for migration step 2 (docs/CONVERSATION-STORE-DESIGN.md §4): +// the private write path goes through the single-writer `ConversationStore` +// intents, and `LegacyConversationStoreBridge` keeps the replace-based +// `LegacyConversationStore` consistent — per-message changes via a +// `Task.yield`-coalesced per-conversation mirror, unread flips and +// structural changes (migration/removal) synchronously — until the feature +// models cut over in step 5. +// +// DELETE IN STEP 5 together with the bridge. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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"]) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 839ceb2d..ce1eee4f 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -655,6 +655,17 @@ private final class PerfDeliveryContext: ChatDeliveryContext { return oldCount - sentReadReceipts.count } + @discardableResult + func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { + guard var chat = privateChats[peerID], + let index = chat.firstIndex(where: { $0.id == messageID }) else { + return false + } + chat[index].deliveryStatus = status + privateChats[peerID] = chat + return true + } + /// 2000 public + `peerCount` x `messagesPerPeer` private messages with /// deterministic IDs and timestamps. static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext { diff --git a/bitchatTests/Services/PrivateChatManagerTests.swift b/bitchatTests/Services/PrivateChatManagerTests.swift index a75dac5f..a986570d 100644 --- a/bitchatTests/Services/PrivateChatManagerTests.swift +++ b/bitchatTests/Services/PrivateChatManagerTests.swift @@ -3,6 +3,8 @@ // bitchatTests // // Tests for PrivateChatManager read receipt and selection behavior. +// Message storage lives in the single-writer ConversationStore; the +// manager's privateChats/unreadMessages are derived views over it. // import Testing @@ -12,13 +14,20 @@ import BitFoundation struct PrivateChatManagerTests { + @MainActor + private static func makeManager(transport: MockTransport) -> (PrivateChatManager, ConversationStore) { + let store = ConversationStore() + let manager = PrivateChatManager(meshService: transport, conversationStore: store) + return (manager, store) + } + @Test @MainActor func startChat_setsSelectedAndClearsUnread() async { let transport = MockTransport() - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) let peerID = PeerID(str: "00000000000000AA") - manager.privateChats[peerID] = [ + store.append( BitchatMessage( id: "pm-1", sender: "Peer", @@ -28,9 +37,10 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: peerID - ) - ] - manager.unreadMessages.insert(peerID) + ), + to: .directPeer(peerID) + ) + store.markUnread(.directPeer(peerID)) manager.startChat(with: peerID) @@ -43,13 +53,13 @@ struct PrivateChatManagerTests { func markAsRead_sendsReadReceiptViaRouter() async { let transport = MockTransport() let router = MessageRouter(transports: [transport]) - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) manager.messageRouter = router let peerID = PeerID(str: "00000000000000BB") transport.reachablePeers.insert(peerID) - manager.privateChats[peerID] = [ + store.append( BitchatMessage( id: "pm-2", sender: "Peer", @@ -59,9 +69,10 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: peerID - ) - ] - manager.unreadMessages.insert(peerID) + ), + to: .directPeer(peerID) + ) + store.markUnread(.directPeer(peerID)) manager.markAsRead(from: peerID) try? await Task.sleep(nanoseconds: 100_000_000) @@ -74,10 +85,10 @@ struct PrivateChatManagerTests { @Test @MainActor func markAsRead_withoutRouterFallsBackToTransport() async { let transport = MockTransport() - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) let peerID = PeerID(str: "00000000000000CC") - manager.privateChats[peerID] = [ + store.append( BitchatMessage( id: "pm-fallback", sender: "Peer", @@ -87,8 +98,9 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: peerID - ) - ] + ), + to: .directPeer(peerID) + ) manager.markAsRead(from: peerID) @@ -99,7 +111,7 @@ struct PrivateChatManagerTests { @Test @MainActor func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async { let transport = MockTransport() - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) let identityManager = MockIdentityManager(MockKeychain()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager) @@ -120,7 +132,7 @@ struct PrivateChatManagerTests { ]) try? await Task.sleep(nanoseconds: 50_000_000) - manager.privateChats[stablePeerID] = [ + store.append( BitchatMessage( id: "stable-msg", sender: "Alice", @@ -130,9 +142,10 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: stablePeerID - ) - ] - manager.unreadMessages.insert(stablePeerID) + ), + to: .directPeer(stablePeerID) + ) + store.markUnread(.directPeer(stablePeerID)) let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: []) @@ -146,11 +159,11 @@ struct PrivateChatManagerTests { @Test @MainActor func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async { let transport = MockTransport() - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) let peerID = PeerID(str: "0011223344556677") let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042") - manager.privateChats[tempPeerID] = [ + store.append( BitchatMessage( id: "geo-msg", sender: "Alice", @@ -160,9 +173,10 @@ struct PrivateChatManagerTests { isPrivate: true, recipientNickname: "Me", senderPeerID: tempPeerID - ) - ] - manager.unreadMessages.insert(tempPeerID) + ), + to: .directPeer(tempPeerID) + ) + store.markUnread(.directPeer(tempPeerID)) let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: []) @@ -177,10 +191,10 @@ struct PrivateChatManagerTests { @Test @MainActor func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async { let transport = MockTransport() - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) let peerID = PeerID(str: "00000000000000DD") - manager.privateChats[peerID] = [ + let seeded = [ BitchatMessage( id: "sent-read", sender: "Me", @@ -215,6 +229,9 @@ struct PrivateChatManagerTests { deliveryStatus: .failed(reason: "nope") ) ] + for message in seeded { + store.append(message, to: .directPeer(peerID)) + } var externalReceipts = Set() manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts) @@ -223,47 +240,36 @@ struct PrivateChatManagerTests { #expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"])) } + /// The store replaces `sanitizeChat`: inserts keep chronological order, + /// duplicate IDs are rejected on append, and `upsertByID` replaces the + /// stored message with the latest copy in place. @Test @MainActor - func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async { + func store_keepsChronologicalOrderAndDedupsByID() async { let transport = MockTransport() - let manager = PrivateChatManager(meshService: transport) + let (manager, store) = Self.makeManager(transport: transport) let peerID = PeerID(str: "00000000000000EE") let base = Date(timeIntervalSince1970: 10) - manager.privateChats[peerID] = [ + func message(_ id: String, _ content: String, offset: TimeInterval) -> BitchatMessage { BitchatMessage( - id: "same", + id: id, sender: "Peer", - content: "Older", - timestamp: base.addingTimeInterval(10), - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerID - ), - BitchatMessage( - id: "first", - sender: "Peer", - content: "First", - timestamp: base, - isRelay: false, - isPrivate: true, - recipientNickname: "Me", - senderPeerID: peerID - ), - BitchatMessage( - id: "same", - sender: "Peer", - content: "Newest", - timestamp: base.addingTimeInterval(20), + content: content, + timestamp: base.addingTimeInterval(offset), isRelay: false, isPrivate: true, recipientNickname: "Me", senderPeerID: peerID ) - ] + } - manager.sanitizeChat(for: peerID) + #expect(store.append(message("same", "Older", offset: 10), to: .directPeer(peerID))) + // Out-of-order arrival is inserted in timestamp order. + #expect(store.append(message("first", "First", offset: 0), to: .directPeer(peerID))) + // Duplicate ID is rejected on append… + #expect(!store.append(message("same", "Newest", offset: 20), to: .directPeer(peerID))) + // …and replaced in place by upsert. + store.upsertByID(message("same", "Newest", offset: 20), in: .directPeer(peerID)) #expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"]) #expect(manager.privateChats[peerID]?.last?.content == "Newest") diff --git a/bitchatTests/TestUtilities/TestHelpers.swift b/bitchatTests/TestUtilities/TestHelpers.swift index c76f57af..c09c60c5 100644 --- a/bitchatTests/TestUtilities/TestHelpers.swift +++ b/bitchatTests/TestUtilities/TestHelpers.swift @@ -138,6 +138,28 @@ enum TestError: Error { case testFailure(String) } +// MARK: - Private chat seeding (ConversationStore migration) + +extension ChatViewModel { + /// Test-only replacement for the deleted `privateChats` setter: seeds a + /// peer's chat through the single-writer `ConversationStore` intents + /// (upsert keeps re-seeding with updated copies working the way the old + /// dictionary assignment did). + @MainActor + func seedPrivateChat(_ messages: [BitchatMessage], for peerID: PeerID) { + _ = conversations.conversation(for: .directPeer(peerID)) + for message in messages { + conversations.upsertByID(message, in: .directPeer(peerID)) + } + } + + /// Test-only: drops every private chat and unread flag. + @MainActor + func clearAllPrivateChats() { + conversations.removeAllDirectConversations() + } +} + func sleep(_ seconds: TimeInterval) async throws { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) } diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 9a2aaef3..52ef76dd 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -359,7 +359,7 @@ struct ViewSmokeTests { makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55) ]) try? await Task.sleep(nanoseconds: 50_000_000) - viewModel.unreadPrivateMessages.insert(blockedPeer) + viewModel.markPrivateChatUnread(blockedPeer) _ = mount( MeshPeerList( From 99d1d1dccd62be069a6fce364fcbcd96b49be58b Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 13:03:07 +0200 Subject: [PATCH 4/7] Cut public message path over to ConversationStore; delete PublicTimelineStore Mesh and geohash timelines are now store conversations. All public mutation sites flow through store intents; PublicMessagePipeline keeps its 80ms UI batching but commits batches via store appends with each buffered entry carrying its destination conversation (a mid-batch channel switch now flushes instead of dropping the buffer). ChatViewModel.messages becomes a cached get-only view of the active conversation, invalidated through the change subject. The mesh late-insert threshold is consciously removed: it only ever ordered the non-rendered messages copy, so strict timestamp insertion makes the working set agree with rendered order. PublicTimelineStore and the per-message full-array legacy sync are deleted; the coalescing bridge mirrors public conversations for the remaining legacy readers. pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady; store.append 237k/s. Co-Authored-By: Claude Fable 5 --- bitchat/App/ConversationStore.swift | 50 ++++ .../App/LegacyConversationStoreBridge.swift | 66 +++-- .../ViewModels/ChatDeliveryCoordinator.swift | 6 +- .../ChatMediaTransferCoordinator.swift | 15 +- .../ViewModels/ChatOutgoingCoordinator.swift | 14 +- .../ChatPublicConversationCoordinator.swift | 225 ++++++------------ bitchat/ViewModels/ChatViewModel.swift | 192 ++++++++++----- .../ChatViewModelBootstrapper.swift | 1 - bitchat/ViewModels/GeoPresenceTracker.swift | 16 +- .../GeohashSubscriptionManager.swift | 20 +- .../ViewModels/PublicMessagePipeline.swift | 98 ++------ bitchat/ViewModels/PublicTimelineStore.swift | 148 ------------ ...MediaTransferCoordinatorContextTests.swift | 27 +-- .../ChatNostrCoordinatorContextTests.swift | 19 +- .../ChatOutgoingCoordinatorContextTests.swift | 31 ++- ...cConversationCoordinatorContextTests.swift | 177 +++++--------- .../ChatViewModelDeliveryStatusTests.swift | 6 +- .../ChatViewModelRefactoringTests.swift | 2 +- bitchatTests/ChatViewModelTests.swift | 22 +- bitchatTests/ConversationStoreTests.swift | 94 ++++++++ .../LegacyConversationStoreBridgeTests.swift | 69 ++++++ .../PerformanceBaselineTests.swift | 10 +- bitchatTests/PublicMessagePipelineTests.swift | 142 +++++------ bitchatTests/PublicTimelineStoreTests.swift | 115 --------- bitchatTests/TestUtilities/TestHelpers.swift | 18 ++ docs/CONVERSATION-STORE-DESIGN.md | 5 +- 26 files changed, 713 insertions(+), 875 deletions(-) delete mode 100644 bitchat/ViewModels/PublicTimelineStore.swift delete mode 100644 bitchatTests/PublicTimelineStoreTests.swift diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 1952b4d8..4e752b9b 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -138,6 +138,23 @@ final class Conversation: ObservableObject, Identifiable { return removed } + /// Removes every message matching `predicate`. Returns the removed + /// message IDs (empty when nothing matched). + fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] { + var removedIDs: [String] = [] + messages.removeAll { message in + guard predicate(message) else { return false } + removedIDs.append(message.id) + return true + } + guard !removedIDs.isEmpty else { return [] } + for id in removedIDs { + indexByMessageID.removeValue(forKey: id) + } + reindex(from: 0) + return removedIDs + } + fileprivate func clearMessages() { messages.removeAll() indexByMessageID.removeAll() @@ -346,6 +363,16 @@ final class ConversationStore: ObservableObject { return removed } + /// Removes every message matching `predicate` from a conversation, + /// emitting one `.messageRemoved` per removed message after the + /// conversation is consistent. No-op for unknown conversations. + func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) { + guard let conversation = conversationsByID[id] else { return } + for messageID in conversation.removeAll(where: predicate) { + changes.send(.messageRemoved(id, messageID: messageID)) + } + } + /// Empties a conversation's timeline but keeps the conversation (and /// its unread/selection state) alive. func clear(_ id: ConversationID) { @@ -464,3 +491,26 @@ extension ConversationStore { } } } + +// MARK: - Migration step 3 compatibility (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. + @discardableResult + func removePublicMessage(withID messageID: String) -> BitchatMessage? { + for (id, conversation) in conversationsByID { + switch id { + case .mesh, .geohash: + if conversation.containsMessage(withID: messageID) { + return removeMessage(withID: messageID, from: id) + } + case .direct: + continue + } + } + return nil + } +} diff --git a/bitchat/App/LegacyConversationStoreBridge.swift b/bitchat/App/LegacyConversationStoreBridge.swift index 7ed57f17..cf412298 100644 --- a/bitchat/App/LegacyConversationStoreBridge.swift +++ b/bitchat/App/LegacyConversationStoreBridge.swift @@ -5,18 +5,20 @@ // 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) -// message state; the feature models (`PrivateInboxModel`, -// `PrivateConversationModel`, `ConversationUIModel`, `PeerListModel`) 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 changes (migration/removal) resynchronize immediately. -// Legacy is therefore eventually consistent within one run-loop tick — the -// same visibility the old `$privateChats` sink provided — while the new -// store stays synchronously authoritative. +// 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 @@ -58,9 +60,10 @@ final class LegacyConversationStoreBridge { /// `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 conversation; pending per-conversation - // work is redundant. - dirtyConversations.removeAll() + // 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(), @@ -92,13 +95,19 @@ private extension LegacyConversationStoreBridge { resynchronizeAll() case .removed(let id): - guard isDirect(id) else { return } - resynchronizeAll() + 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) { - guard isDirect(id) else { return } dirtyConversations.insert(id) scheduleFlush() } @@ -127,16 +136,21 @@ private extension LegacyConversationStoreBridge { } func mirrorConversation(_ id: ConversationID) { - guard case .direct(let handle) = id, - let conversation = store.conversationsByID[id] else { - // Removed while dirty; the removal already resynchronized. + guard let conversation = store.conversationsByID[id] else { + // Removed while dirty; the removal already resynchronized + // (direct) or mirrored an empty timeline (public). return } - legacyStore.replaceDirectMessages( - conversation.messages, - for: handle.routingPeerID, - identityResolver: identityResolver - ) + 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 { diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index e94a5a73..8acf7bec 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -12,7 +12,11 @@ import Foundation /// coordinators off their `unowned let viewModel: ChatViewModel` back-refs. @MainActor protocol ChatDeliveryContext: AnyObject { - var messages: [BitchatMessage] { get set } + /// Get-only derived views of the `ConversationStore`. `BitchatMessage` + /// is a reference type, so the public-timeline status patch below writes + /// through the shared message objects; step 4 replaces this with + /// `setDeliveryStatus(for:in:)` store intents. + var messages: [BitchatMessage] { get } var privateChats: [PeerID: [BitchatMessage]] { get } var isStartupPhase: Bool { get } /// Applies a delivery status to a private message by ID (single-writer diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 54ce27ed..d604f8f8 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -29,9 +29,10 @@ protocol ChatMediaTransferContext: AnyObject { /// Appends a private message via the single-writer store intent. @discardableResult func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) - func refreshVisibleMessages(from channel: ChannelID?) - func trimMessagesIfNeeded() + /// Appends a public message via the single-writer store intent + /// (immediate: outgoing media placeholders must render without batching). + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool func removeMessage(withID messageID: String, cleanupFile: Bool) func addSystemMessage(_ content: String) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). @@ -52,8 +53,7 @@ extension ChatViewModel: ChatMediaTransferContext { // `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`, // `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`, // `currentPublicSender()`, `privateChats`, - // `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`, - // `trimMessagesIfNeeded()`, `removeMessage(withID:cleanupFile:)`, + // `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`, // `addSystemMessage(_:)`, `notifyUIChanged()`, // `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`, // and `recordContentKey(_:timestamp:)` are shared requirements with the @@ -232,7 +232,6 @@ final class ChatMediaTransferCoordinator { deliveryStatus: .sending ) context.appendPrivateMessage(message, to: peerID) - context.trimMessagesIfNeeded() } else { let (displayName, senderPeerID) = context.currentPublicSender() message = BitchatMessage( @@ -246,9 +245,7 @@ final class ChatMediaTransferCoordinator { senderPeerID: senderPeerID, deliveryStatus: .sending ) - context.appendTimelineMessage(message, to: context.activeChannel) - context.refreshVisibleMessages(from: context.activeChannel) - context.trimMessagesIfNeeded() + context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel)) } let key = context.normalizedContentKey(message.content) diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 5cf5d87b..915ea7fa 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -25,9 +25,10 @@ protocol ChatOutgoingContext: AnyObject { // MARK: Public timeline (local echo) func parseMentions(from content: String) -> [String] - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) - func refreshVisibleMessages(from channel: ChannelID?) - func trimMessagesIfNeeded() + /// Appends a public message via the single-writer store intent + /// (immediate: the local echo must render without batching). + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool func addSystemMessage(_ content: String) // MARK: Content dedup @@ -50,8 +51,7 @@ extension ChatViewModel: ChatOutgoingContext { // `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`, // `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`, // `sendPrivateMessage(_:to:)`, `parseMentions(from:)`, - // `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`, - // `trimMessagesIfNeeded()`, `addSystemMessage(_:)`, + // `appendPublicMessage(_:to:)`, `addSystemMessage(_:)`, // `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`, // `sendMeshMessage(_:mentions:messageID:timestamp:)`, // `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are @@ -169,12 +169,10 @@ private extension ChatOutgoingCoordinator { } func appendLocalEcho(_ message: BitchatMessage) { - context.appendTimelineMessage(message, to: context.activeChannel) - context.refreshVisibleMessages(from: context.activeChannel) + context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel)) let contentKey = context.normalizedContentKey(message.content) context.recordContentKey(contentKey, timestamp: message.timestamp) - context.trimMessagesIfNeeded() } func routePublicMessage( diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 2cb1ddd9..4a6dab4a 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -19,8 +19,7 @@ import UIKit /// pipeline. @MainActor protocol ChatPublicConversationContext: AnyObject { - // MARK: Channel & visible timeline state - var messages: [BitchatMessage] { get set } + // MARK: Channel state var activeChannel: ChannelID { get } var currentGeohash: String? { get } var nickname: String { get } @@ -31,25 +30,26 @@ protocol ChatPublicConversationContext: AnyObject { func setPublicBatching(_ isBatching: Bool) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() - func trimMessagesIfNeeded() - // MARK: Public timeline store - func timelineMessages(for channel: ChannelID) -> [BitchatMessage] - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) + // MARK: Public conversation store (single-writer intents) + /// Appends a public message in timestamp order. Returns `false` when a + /// message with the same ID is already in that conversation. + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool + /// Appends a geohash message if absent. Returns `true` when stored. + @discardableResult func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool - func removeTimelineMessage(withID id: String) -> BitchatMessage? - func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) - func clearTimeline(for channel: ChannelID) - func timelineGeohashKeys() -> [String] + func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool + /// Removes a message by ID from whichever public conversation contains it. + @discardableResult + func removePublicMessage(withID messageID: String) -> BitchatMessage? + /// Removes every matching message from a geohash conversation (block purge). + func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) + /// Empties a public conversation's timeline (`/clear`). + func clearPublicConversation(_ conversationID: ConversationID) /// Queues a system message for the next geohash channel visit. func queueGeohashSystemMessage(_ content: String) - // MARK: Conversation stores - func setConversationActiveChannel(_ channel: ChannelID) - func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) - func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) - func synchronizeConversationSelectionStore() - // MARK: Private chats (block cleanup & message removal) var privateChats: [PeerID: [BitchatMessage]] { get } /// Removes the peer's chat entirely, including unread state @@ -84,7 +84,9 @@ protocol ChatPublicConversationContext: AnyObject { func processActionMessage(_ message: BitchatMessage) -> BitchatMessage func isMessageBlocked(_ message: BitchatMessage) -> Bool func allowPublicMessage(senderKey: String, contentKey: String) -> Bool - func enqueuePublicMessage(_ message: BitchatMessage) + /// Buffers a visible-channel message for the batched (~80 ms) pipeline + /// flush, which commits it to `conversationID` in the store. + func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? // MARK: Content dedup & formatting @@ -100,55 +102,21 @@ protocol ChatPublicConversationContext: AnyObject { } extension ChatViewModel: ChatPublicConversationContext { - // `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`, + // `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`, // `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`, // `myPeerID`, `isTeleported`, `notifyUIChanged()`, // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, - // `deriveNostrIdentity(forGeohash:)`, and - // `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements - // with `ChatDeliveryContext` / `ChatPrivateConversationContext` / - // `ChatNostrContext`; their witnesses already exist. The members below - // flatten nested service accesses into intent-named calls. - - func timelineMessages(for channel: ChannelID) -> [BitchatMessage] { - timelineStore.messages(for: channel) - } - - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { - timelineStore.append(message, to: channel) - } - - func removeTimelineMessage(withID id: String) -> BitchatMessage? { - timelineStore.removeMessage(withID: id) - } - - func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { - timelineStore.removeMessages(in: geohash, where: predicate) - } - - func clearTimeline(for channel: ChannelID) { - timelineStore.clear(channel: channel) - } - - func timelineGeohashKeys() -> [String] { - timelineStore.geohashKeys() - } - - func queueGeohashSystemMessage(_ content: String) { - timelineStore.queueGeohashSystemMessage(content) - } - - func setConversationActiveChannel(_ channel: ChannelID) { - conversationStore.setActiveChannel(channel) - } - - func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { - conversationStore.replaceMessages(messages, for: channelID) - } - - func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { - conversationStore.replaceMessages(messages, for: conversationID) - } + // `deriveNostrIdentity(forGeohash:)`, the public conversation store + // intents (`appendPublicMessage(_:to:)`, + // `appendGeohashMessageIfAbsent(_:toGeohash:)`, + // `publicConversationContainsMessage(withID:in:)`, + // `removePublicMessage(withID:)`, + // `removePublicMessages(fromGeohash:where:)`, + // `clearPublicConversation(_:)`, and `queueGeohashSystemMessage(_:)`) + // are shared requirements with `ChatDeliveryContext` / + // `ChatPrivateConversationContext` / `ChatNostrContext` or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. func visibleGeoPeople() -> [GeoPerson] { participantTracker.getVisiblePeople() @@ -174,8 +142,8 @@ extension ChatViewModel: ChatPublicConversationContext { publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) } - func enqueuePublicMessage(_ message: BitchatMessage) { - publicMessagePipeline.enqueue(message) + func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { + publicMessagePipeline.enqueue(message, to: conversationID) } func normalizedContentKey(_ content: String) -> String { @@ -247,11 +215,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } return false } - context.removeGeohashTimelineMessages(in: gh, where: predicate) - synchronizePublicConversationStore(forGeohash: gh) - if case .location = context.activeChannel { - context.messages.removeAll(where: predicate) - } + context.removePublicMessages(fromGeohash: gh, where: predicate) } let conversationPeerID = PeerID(nostr_: hex) @@ -313,16 +277,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func removeMessage(withID messageID: String, cleanupFile: Bool = false) { - var removedMessage: BitchatMessage? - - if let index = context.messages.firstIndex(where: { $0.id == messageID }) { - removedMessage = context.messages.remove(at: index) - } - - if let storeRemoved = context.removeTimelineMessage(withID: messageID) { - removedMessage = removedMessage ?? storeRemoved - synchronizeAllPublicConversationStores() - } + var removedMessage = context.removePublicMessage(withID: messageID) if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) { removedMessage = removedMessage ?? removedPrivateMessage @@ -335,45 +290,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.notifyUIChanged() } - func initializeConversationStore() { - context.setConversationActiveChannel(context.activeChannel) - synchronizePublicConversationStore(for: context.activeChannel) - context.synchronizeConversationSelectionStore() - } - - func synchronizePublicConversationStore(for channel: ChannelID) { - let publicMessages = context.timelineMessages(for: channel) - context.replaceConversationMessages(publicMessages, for: channel) - if channel == context.activeChannel { - context.setConversationActiveChannel(context.activeChannel) - } - } - - func synchronizePublicConversationStore(forGeohash geohash: String) { - let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let publicMessages = context.timelineMessages(for: channel) - context.replaceConversationMessages(publicMessages, for: .geohash(geohash.lowercased())) - } - - func synchronizeAllPublicConversationStores() { - synchronizePublicConversationStore(for: .mesh) - for geohash in context.timelineGeohashKeys() { - synchronizePublicConversationStore(forGeohash: geohash) - } - } - - func refreshVisibleMessages(from channel: ChannelID? = nil) { - let target = channel ?? context.activeChannel - context.messages = context.timelineMessages(for: target) - context.replaceConversationMessages(context.messages, for: target) - if target == context.activeChannel { - context.setConversationActiveChannel(context.activeChannel) - } - } - func clearCurrentPublicTimeline() { - context.messages.removeAll() - context.clearTimeline(for: context.activeChannel) + context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) Task.detached(priority: .utility) { do { @@ -413,7 +331,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { timestamp: timestamp, isRelay: false ) - context.messages.append(systemMessage) + context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel)) } func addMeshOnlySystemMessage(_ content: String) { @@ -423,11 +341,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { timestamp: Date(), isRelay: false ) - context.appendTimelineMessage(systemMessage, to: .mesh) - synchronizePublicConversationStore(for: .mesh) - refreshVisibleMessages() - context.trimMessagesIfNeeded() - context.notifyUIChanged() + context.appendPublicMessage(systemMessage, to: .mesh) } func addPublicSystemMessage(_ content: String) { @@ -437,12 +351,9 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { timestamp: Date(), isRelay: false ) - context.appendTimelineMessage(systemMessage, to: context.activeChannel) - refreshVisibleMessages(from: context.activeChannel) + context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel)) let contentKey = context.normalizedContentKey(systemMessage.content) context.recordContentKey(contentKey, timestamp: systemMessage.timestamp) - context.trimMessagesIfNeeded() - context.notifyUIChanged() } func addGeohashOnlySystemMessage(_ content: String) { @@ -492,7 +403,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if context.isMessageBlocked(finalMessage) { return } let isGeo = finalMessage.senderPeerID?.isGeoChat == true - let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil + let isSystem = finalMessage.sender == "system" + let shouldRateLimit = !isSystem || finalMessage.senderPeerID != nil if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) let contentKey = context.normalizedContentKey(finalMessage.content) @@ -501,20 +413,26 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } } - if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } + if !isSystem && finalMessage.content.count > 16000 { return } + // Empty content never rendered before (the old visible-array enqueue + // filtered it); with the store as the sole timeline it is dropped + // outright instead of lingering invisibly in a backing buffer. + guard !finalMessage.content.trimmed.isEmpty else { return } - if !isGeo && finalMessage.sender != "system" { - context.appendTimelineMessage(finalMessage, to: .mesh) - synchronizePublicConversationStore(for: .mesh) + // Resolve the destination conversation. System messages surface on + // the active channel (matching their old visible-only routing); geo + // messages require a current geohash, mesh messages always land in + // the mesh conversation. + let destination: ConversationID? + if isSystem { + destination = ConversationID(channelID: context.activeChannel) + } else if isGeo { + destination = context.currentGeohash.map { .geohash($0.lowercased()) } + } else { + destination = .mesh } + guard let destination else { return } - if isGeo && finalMessage.sender != "system", - let geohash = context.currentGeohash, - context.appendGeohashMessageIfAbsent(finalMessage, toGeohash: geohash) { - synchronizePublicConversationStore(forGeohash: geohash) - } - - let isSystem = finalMessage.sender == "system" let channelMatches: Bool = { switch context.activeChannel { case .mesh: return !isGeo || isSystem @@ -522,11 +440,16 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } }() - guard channelMatches else { return } - - if !finalMessage.content.trimmed.isEmpty, - !context.messages.contains(where: { $0.id == finalMessage.id }) { - context.enqueuePublicMessage(finalMessage) + if channelMatches { + // Visible-channel arrivals are batched: the pipeline's ~80 ms + // flush commits them to the store (which dedups by ID), keeping + // the deliberate UI flush cadence. + guard !context.publicConversationContainsMessage(withID: finalMessage.id, in: destination) else { return } + context.enqueuePublicMessage(finalMessage, to: destination) + } else { + // Background-channel arrivals have no rendering observers to + // batch for; they land in the store immediately. + context.appendPublicMessage(finalMessage, to: destination) } } @@ -584,14 +507,6 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { #endif } - func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { - context.messages - } - - func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { - context.messages = messages - } - func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { context.normalizedContentKey(content) } @@ -604,8 +519,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.recordContentKey(key, timestamp: timestamp) } - func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { - context.trimMessagesIfNeeded() + func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { + context.appendPublicMessage(message, to: conversationID) } func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 948514db..8be04fc0 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -119,9 +119,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Published Properties - @Published var messages: [BitchatMessage] = [] + /// 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`. + @MainActor + var messages: [BitchatMessage] { + if let cached = visibleMessagesCache { return cached } + // Read-only lookup (never creates the conversation): this getter + // runs during SwiftUI renders, where mutating the store's + // `@Published` collections would publish mid-view-update. + let current = conversations.conversationsByID[ConversationID(channelID: activeChannel)]?.messages ?? [] + visibleMessagesCache = current + return current + } + private var visibleMessagesCache: [BitchatMessage]? @Published var currentColorScheme: ColorScheme = .light - private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed @Published var isConnected = false @Published var nickname: String = "" { didSet { @@ -298,9 +313,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele get { conversationStore.activeChannel } set { guard conversationStore.activeChannel != newValue else { return } - publicMessagePipeline.updateActiveChannel(newValue) conversationStore.setActiveChannel(newValue) - synchronizePublicConversationStore(for: newValue) + visibleMessagesCache = nil synchronizeConversationSelectionStore() objectWillChange.send() } @@ -352,11 +366,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown - var timelineStore = PublicTimelineStore( - meshCap: TransportConfig.meshTimelineCap, - geohashCap: TransportConfig.geoTimelineCap - ) - private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { if Thread.isMainThread { MainActor.assumeIsolated { @@ -631,6 +640,83 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele return removed } + // MARK: - Public Conversation Store Intents + // The sole mutation paths for public (mesh/geohash) message state, + // mirroring the private intents above. The store's per-conversation cap + // and timestamp-ordered insert replace `PublicTimelineStore`'s trim and + // the pipeline's late-insert positioning; the read-only `messages` shim + // above is derived from the same store. + + /// Appends a public message in timestamp order. Returns `false` when a + /// message with the same ID is already in that conversation (O(1) dedup + /// via the conversation's ID index). + @MainActor + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { + conversations.append(message, to: conversationID) + } + + /// Appends a geohash message if absent. Returns `true` when stored + /// (the legacy `PublicTimelineStore.appendIfAbsent` contract). + @MainActor + @discardableResult + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + conversations.append(message, to: .geohash(geohash.lowercased())) + } + + /// A public (mesh/geohash) channel's full timeline. + @MainActor + func publicMessages(for channel: ChannelID) -> [BitchatMessage] { + conversations.conversation(for: ConversationID(channelID: channel)).messages + } + + /// `true` when the conversation contains a message with `messageID`. + @MainActor + func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool { + conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false + } + + /// Removes a message by ID from whichever public conversation contains + /// it. Returns the removed message, if any. + @MainActor + @discardableResult + func removePublicMessage(withID messageID: String) -> BitchatMessage? { + conversations.removePublicMessage(withID: messageID) + } + + /// Removes every message matching `predicate` from a geohash + /// conversation (block-user purge). + @MainActor + func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) { + conversations.removeMessages(from: .geohash(geohash.lowercased()), where: predicate) + } + + /// Empties a public conversation's timeline (`/clear`). + @MainActor + func clearPublicConversation(_ conversationID: ConversationID) { + conversations.clear(conversationID) + } + + /// Queues a system message for the next geohash channel visit. (Tiny + /// UI-flow queue formerly on `PublicTimelineStore`; it is notice text, + /// not conversation state, so it stays on the owner.) + @MainActor + func queueGeohashSystemMessage(_ content: String) { + pendingGeohashSystemMessages.append(content) + } + + /// Drains the queued geohash system messages (single consumer: + /// `GeohashSubscriptionManager.switchLocationChannel`). + @MainActor + func drainPendingGeohashSystemMessages() -> [String] { + defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } + return pendingGeohashSystemMessages + } + + // Single-writer: mutate only via `queueGeohashSystemMessage(_:)` / + // `drainPendingGeohashSystemMessages()` above. + private var pendingGeohashSystemMessages: [String] = [] + // MARK: - Initialization @MainActor @@ -717,12 +803,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // 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 (their debounced - // Legacy synchronization sinks are gone; the bridge above feeds - // Legacy instead). + // `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. conversations.changes - .sink { [weak self] _ in - self?.objectWillChange.send() + .sink { [weak self] change in + guard let self else { return } + if self.changeAffectsActivePublicConversation(change) { + self.visibleMessagesCache = nil + } + self.objectWillChange.send() } .store(in: &cancellables) @@ -1008,13 +1100,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func panicClearAllData() { // Messages are processed immediately - nothing to flush - // Clear all messages - messages.removeAll() - timelineStore = PublicTimelineStore( - meshCap: TransportConfig.meshTimelineCap, - geohashCap: TransportConfig.geoTimelineCap - ) - conversations.removeAllDirectConversations() + // Clear all messages (public timelines and private chats live in the + // single-writer ConversationStore; the derived `messages` view and + // the legacy mirror empty with it) + conversations.clearAll() + pendingGeohashSystemMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) _ = keychain.deleteAllKeychainData() @@ -1190,22 +1280,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor func initializeConversationStore() { - publicConversationCoordinator.initializeConversationStore() - } - - @MainActor - func synchronizePublicConversationStore(for channel: ChannelID) { - publicConversationCoordinator.synchronizePublicConversationStore(for: channel) - } - - @MainActor - func synchronizePublicConversationStore(forGeohash geohash: String) { - publicConversationCoordinator.synchronizePublicConversationStore(forGeohash: geohash) - } - - @MainActor - func synchronizeAllPublicConversationStores() { - publicConversationCoordinator.synchronizeAllPublicConversationStores() + conversationStore.setActiveChannel(activeChannel) + synchronizeConversationSelectionStore() } /// Full Legacy-store resynchronization from the new `ConversationStore`. @@ -1226,15 +1302,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } - func trimMessagesIfNeeded() { - if messages.count > maxMessages { - messages = Array(messages.suffix(maxMessages)) - } - } - + /// Invalidates the derived `messages` cache and notifies observers. + /// (Formerly pulled the channel's timeline into a stored `messages` + /// array; `messages` is now derived from the `ConversationStore`, so + /// only the invalidation remains. The `channel` parameter is kept for + /// call-site compatibility — every caller passes the active channel.) @MainActor func refreshVisibleMessages(from channel: ChannelID? = nil) { - publicConversationCoordinator.refreshVisibleMessages(from: channel) + visibleMessagesCache = nil + objectWillChange.send() + } + + /// `true` when a store change touches the active public conversation + /// (so the derived `messages` cache must be invalidated). + @MainActor + private func changeAffectsActivePublicConversation(_ change: ConversationChange) -> Bool { + let activeID = ConversationID(channelID: activeChannel) + switch change { + case .appended(let id, _), + .updated(let id, _), + .statusChanged(let id, _, _), + .messageRemoved(let id, _), + .cleared(let id), + .removed(let id), + .unreadChanged(let id, _): + return id == activeID + case .migrated(let source, let destination): + return source == activeID || destination == activeID + } } @MainActor @@ -1276,15 +1371,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.clearCurrentPublicTimeline() } - // MARK: - Message Management - - private func addMessage(_ message: BitchatMessage) { - // Check for duplicates - guard !messages.contains(where: { $0.id == message.id }) else { return } - messages.append(message) - trimMessagesIfNeeded() - } - // MARK: - Peer Lookup Helpers func getPeer(byID peerID: PeerID) -> BitchatPeer? { diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 5e1b71d4..9be99cfe 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -131,7 +131,6 @@ private extension ChatViewModelBootstrapper { viewModel.meshService.startServices() viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator - viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in guard let viewModel, diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index 100041a9..ffe423b5 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -23,8 +23,10 @@ protocol GeoPresenceContext: AnyObject { func geoParticipantCount(for geohash: String) -> Int func markGeoTeleported(_ pubkeyHexLowercased: String) + /// Appends a geohash message if absent (single-writer store intent). + /// Returns `true` when stored. + @discardableResult func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool - func synchronizePublicConversationStore(forGeohash geohash: String) /// Posts the sampled-geohash-activity local notification. func notifyGeohashActivity(geohash: String, bodyPreview: String) @@ -32,13 +34,10 @@ protocol GeoPresenceContext: AnyObject { extension ChatViewModel: GeoPresenceContext { // `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr - // identity/blocking members, and `synchronizePublicConversationStore` - // already have witnesses on `ChatViewModel`. The members below flatten - // nested service accesses into intent-named calls. - - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - timelineStore.appendIfAbsent(message, toGeohash: geohash) - } + // identity/blocking members, and the + // `appendGeohashMessageIfAbsent(_:toGeohash:)` store intent already have + // witnesses on `ChatViewModel`. The members below flatten nested service + // accesses into intent-named calls. var teleportedGeoCount: Int { locationPresenceStore.teleportedGeo.count @@ -166,7 +165,6 @@ final class GeoPresenceTracker { mentions: mentions.isEmpty ? nil : mentions ) if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) { - context.synchronizePublicConversationStore(forGeohash: gh) context.notifyGeohashActivity(geohash: gh, bodyPreview: preview) } } diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift index 1553410a..4e267c59 100644 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -27,8 +27,9 @@ protocol GeohashSubscriptionContext: AnyObject { // MARK: Public timeline & pipeline var messages: [BitchatMessage] { get } - func resetPublicMessagePipeline() - func updatePublicMessagePipelineChannel(_ channel: ChannelID) + /// Commits any batched-but-unflushed public messages to the store so a + /// channel switch never strands them in the pipeline buffer. + func flushPublicMessagePipeline() func refreshVisibleMessages(from channel: ChannelID?) func addPublicSystemMessage(_ content: String) func drainPendingGeohashSystemMessages() -> [String] @@ -64,16 +65,8 @@ extension ChatViewModel: GeohashSubscriptionContext { // `ChatViewModel`. The members below flatten nested service accesses into // intent-named calls. - func resetPublicMessagePipeline() { - publicMessagePipeline.reset() - } - - func updatePublicMessagePipelineChannel(_ channel: ChannelID) { - publicMessagePipeline.updateActiveChannel(channel) - } - - func drainPendingGeohashSystemMessages() -> [String] { - timelineStore.drainPendingGeohashSystemMessages() + func flushPublicMessagePipeline() { + publicMessagePipeline.flushIfNeeded() } func clearProcessedNostrEvents() { @@ -178,9 +171,8 @@ final class GeohashSubscriptionManager { @MainActor func switchLocationChannel(to channel: ChannelID) { guard let context else { return } - context.resetPublicMessagePipeline() + context.flushPublicMessagePipeline() context.activeChannel = channel - context.updatePublicMessagePipelineChannel(channel) context.clearProcessedNostrEvents() switch channel { diff --git a/bitchat/ViewModels/PublicMessagePipeline.swift b/bitchat/ViewModels/PublicMessagePipeline.swift index 516160e9..7383151b 100644 --- a/bitchat/ViewModels/PublicMessagePipeline.swift +++ b/bitchat/ViewModels/PublicMessagePipeline.swift @@ -2,7 +2,11 @@ // PublicMessagePipeline.swift // bitchat // -// Handles batching and deduplication of public chat messages before surfacing them to the UI. +// Batches visible-channel public messages before committing them to the +// ConversationStore: the deliberate ~80 ms UI flush cadence survives the +// store cutover, while ordering, dedup, and caps live in the store itself +// (its timestamp-ordered insert replaced this pipeline's late-insert +// threshold positioning; see docs/CONVERSATION-STORE-DESIGN.md). // import BitFoundation @@ -10,12 +14,13 @@ import Foundation @MainActor protocol PublicMessagePipelineDelegate: AnyObject { - func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] - func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) - func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) + /// Commits a batched message to its conversation in the store. + /// Returns `false` when the message was already present (ID dedup). + @discardableResult + func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) } @@ -24,14 +29,13 @@ protocol PublicMessagePipelineDelegate: AnyObject { final class PublicMessagePipeline { weak var delegate: PublicMessagePipelineDelegate? - private var buffer: [BitchatMessage] = [] + private var buffer: [(message: BitchatMessage, conversationID: ConversationID)] = [] private var timer: Timer? private let baseFlushInterval: TimeInterval private var dynamicFlushInterval: TimeInterval private var recentBatchSizes: [Int] = [] private let maxRecentBatchSamples: Int private let dedupWindow: TimeInterval - private var activeChannel: ChannelID = .mesh init( baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval, @@ -48,25 +52,17 @@ final class PublicMessagePipeline { timer?.invalidate() } - func updateActiveChannel(_ channel: ChannelID) { - activeChannel = channel - } - - func enqueue(_ message: BitchatMessage) { - buffer.append(message) + /// Buffers a message destined for `conversationID`; the next batched + /// flush commits it to the store. Each entry carries its destination so + /// a channel switch mid-batch can never misroute buffered messages. + func enqueue(_ message: BitchatMessage, to conversationID: ConversationID) { + buffer.append((message, conversationID)) scheduleFlush() } func flushIfNeeded() { flushBuffer() } - - func reset() { - timer?.invalidate() - timer = nil - buffer.removeAll(keepingCapacity: false) - } - } private extension PublicMessagePipeline { @@ -91,57 +87,38 @@ private extension PublicMessagePipeline { delegate.pipelineSetBatchingState(self, isBatching: true) - var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id }) - var pending: [(message: BitchatMessage, contentKey: String)] = [] + // Content-window dedup against recorded keys and within the batch; + // ID dedup happens in the store at commit time. + var pending: [(message: BitchatMessage, conversationID: ConversationID, contentKey: String)] = [] var batchContentLatest: [String: Date] = [:] - for message in buffer { - if existingIDs.contains(message.id) { continue } - let contentKey = delegate.pipeline(self, normalizeContent: message.content) + for item in buffer { + let contentKey = delegate.pipeline(self, normalizeContent: item.message.content) if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey), - abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow { + abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow { continue } if let ts = batchContentLatest[contentKey], - abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow { + abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow { continue } - existingIDs.insert(message.id) - pending.append((message, contentKey)) - batchContentLatest[contentKey] = message.timestamp + pending.append((item.message, item.conversationID, contentKey)) + batchContentLatest[contentKey] = item.message.timestamp } buffer.removeAll(keepingCapacity: true) guard !pending.isEmpty else { delegate.pipelineSetBatchingState(self, isBatching: false) - if !buffer.isEmpty { scheduleFlush() } return } pending.sort { $0.message.timestamp < $1.message.timestamp } - var messages = delegate.pipelineCurrentMessages(self) - let threshold = lateInsertThreshold(for: activeChannel) - let lastTimestamp = messages.last?.timestamp ?? .distantPast - for item in pending { - let message = item.message - if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) { - let index = insertionIndex(for: message.timestamp, in: messages) - if index >= messages.count { - messages.append(message) - } else { - messages.insert(message, at: index) - } - } else { - messages.append(message) - } - delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp) + guard delegate.pipeline(self, commit: item.message, to: item.conversationID) else { continue } + delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: item.message.timestamp) } - delegate.pipeline(self, setMessages: messages) - delegate.pipelineTrimMessages(self) - updateFlushInterval(withBatchSize: pending.count) for item in pending { @@ -165,27 +142,4 @@ private extension PublicMessagePipeline { : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count) dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval } - - func lateInsertThreshold(for channel: ChannelID) -> TimeInterval { - switch channel { - case .mesh: - return TransportConfig.uiLateInsertThreshold - case .location: - return TransportConfig.uiLateInsertThresholdGeo - } - } - - func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int { - var low = 0 - var high = messages.count - while low < high { - let mid = (low + high) / 2 - if messages[mid].timestamp < timestamp { - low = mid + 1 - } else { - high = mid - } - } - return low - } } diff --git a/bitchat/ViewModels/PublicTimelineStore.swift b/bitchat/ViewModels/PublicTimelineStore.swift deleted file mode 100644 index ae613f2b..00000000 --- a/bitchat/ViewModels/PublicTimelineStore.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// PublicTimelineStore.swift -// bitchat -// -// Maintains mesh and geohash public timelines with simple caps and helpers. -// - -import BitFoundation -import Foundation - -struct PublicTimelineStore { - private var meshTimeline: [BitchatMessage] = [] - private var meshMessageIDs: Set = [] - private var geohashTimelines: [String: [BitchatMessage]] = [:] - private var geohashMessageIDs: [String: Set] = [:] - private var pendingGeohashSystemMessages: [String] = [] - - private let meshCap: Int - private let geohashCap: Int - - init(meshCap: Int, geohashCap: Int) { - self.meshCap = meshCap - self.geohashCap = geohashCap - } - - mutating func append(_ message: BitchatMessage, to channel: ChannelID) { - switch channel { - case .mesh: - guard !meshMessageIDs.contains(message.id) else { return } - meshTimeline.append(message) - meshMessageIDs.insert(message.id) - trimMeshTimelineIfNeeded() - case .location(let channel): - append(message, toGeohash: channel.geohash) - } - } - - mutating func append(_ message: BitchatMessage, toGeohash geohash: String) { - _ = appendGeohashMessageIfAbsent(message, geohash: geohash) - } - - /// Append message if absent, returning true when stored. - mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - appendGeohashMessageIfAbsent(message, geohash: geohash) - } - - mutating func messages(for channel: ChannelID) -> [BitchatMessage] { - switch channel { - case .mesh: - return meshTimeline - case .location(let channel): - let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? [] - replaceGeohashTimeline(cleaned, for: channel.geohash, keepEmpty: true) - return cleaned - } - } - - mutating func clear(channel: ChannelID) { - switch channel { - case .mesh: - meshTimeline.removeAll() - meshMessageIDs.removeAll() - case .location(let channel): - geohashTimelines[channel.geohash] = [] - geohashMessageIDs[channel.geohash] = [] - } - } - - @discardableResult - mutating func removeMessage(withID id: String) -> BitchatMessage? { - if let index = meshTimeline.firstIndex(where: { $0.id == id }) { - let removed = meshTimeline.remove(at: index) - meshMessageIDs.remove(id) - return removed - } - - for key in Array(geohashTimelines.keys) { - var timeline = geohashTimelines[key] ?? [] - if let index = timeline.firstIndex(where: { $0.id == id }) { - let removed = timeline.remove(at: index) - replaceGeohashTimeline(timeline, for: key, keepEmpty: false) - return removed - } - } - - return nil - } - - mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { - var timeline = geohashTimelines[geohash] ?? [] - timeline.removeAll(where: predicate) - replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false) - } - - mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) { - var timeline = geohashTimelines[geohash] ?? [] - transform(&timeline) - replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false) - } - - mutating func queueGeohashSystemMessage(_ content: String) { - pendingGeohashSystemMessages.append(content) - } - - mutating func drainPendingGeohashSystemMessages() -> [String] { - defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } - return pendingGeohashSystemMessages - } - - func geohashKeys() -> [String] { - Array(geohashTimelines.keys) - } - - private mutating func trimMeshTimelineIfNeeded() { - guard meshTimeline.count > meshCap else { return } - meshTimeline = Array(meshTimeline.suffix(meshCap)) - meshMessageIDs = Set(meshTimeline.map(\.id)) - } - - private mutating func appendGeohashMessageIfAbsent(_ message: BitchatMessage, geohash: String) -> Bool { - var timeline = geohashTimelines[geohash] ?? [] - var messageIDs = geohashMessageIDs[geohash] ?? Set(timeline.map(\.id)) - guard messageIDs.insert(message.id).inserted else { return false } - - timeline.append(message) - trimGeohashTimelineIfNeeded(&timeline, messageIDs: &messageIDs) - geohashTimelines[geohash] = timeline - geohashMessageIDs[geohash] = messageIDs - return true - } - - private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage], messageIDs: inout Set) { - guard timeline.count > geohashCap else { return } - timeline = Array(timeline.suffix(geohashCap)) - messageIDs = Set(timeline.map(\.id)) - } - - private mutating func replaceGeohashTimeline(_ timeline: [BitchatMessage], for geohash: String, keepEmpty: Bool) { - if timeline.isEmpty && !keepEmpty { - geohashTimelines[geohash] = nil - geohashMessageIDs[geohash] = nil - return - } - - geohashTimelines[geohash] = timeline - geohashMessageIDs[geohash] = Set(timeline.map(\.id)) - } -} diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 32cf8276..60932a5a 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -52,23 +52,17 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { return true } - var meshTimeline: [BitchatMessage] = [] - private(set) var refreshedChannels: [ChannelID?] = [] - private(set) var trimCount = 0 + private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] private(set) var systemMessages: [String] = [] private(set) var notifyUIChangedCount = 0 - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { - meshTimeline.append(message) + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { + appendedPublicMessages.append((message, conversationID)) + return true } - func refreshVisibleMessages(from channel: ChannelID?) { - refreshedChannels.append(channel) - } - - func trimMessagesIfNeeded() { trimCount += 1 } - func removeMessage(withID messageID: String, cleanupFile: Bool) { removedMessages.append((messageID, cleanupFile)) } @@ -129,22 +123,21 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(message.senderPeerID == context.myPeerID) #expect(message.deliveryStatus == .sending) #expect(context.recordedContentKeys == ["[voice] note.m4a"]) - #expect(context.trimCount == 1) #expect(context.notifyUIChangedCount == 1) - #expect(context.meshTimeline.isEmpty) + #expect(context.appendedPublicMessages.isEmpty) } @Test @MainActor - func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async { + func enqueueMediaMessage_publicAppendsToActiveConversation() async { let context = MockChatMediaTransferContext() let coordinator = ChatMediaTransferCoordinator(context: context) let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil) - #expect(context.meshTimeline.map(\.id) == [message.id]) + #expect(context.appendedPublicMessages.map(\.message.id) == [message.id]) + #expect(context.appendedPublicMessages.first?.conversationID == .mesh) #expect(!message.isPrivate) #expect(message.sender == "me") - #expect(context.refreshedChannels.count == 1) #expect(context.privateChats.isEmpty) #expect(context.notifyUIChangedCount == 1) } @@ -210,7 +203,7 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(context.systemMessages == ["Voice notes are only available in mesh chats."]) #expect(context.privateChats.isEmpty) - #expect(context.meshTimeline.isEmpty) + #expect(context.appendedPublicMessages.isEmpty) #expect(coordinator.transferIdToMessageIDs.isEmpty) } } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index d9d68c4c..7244f366 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -42,16 +42,13 @@ private final class MockChatNostrContext: ChatNostrContext { // Public timeline & pipeline var messages: [BitchatMessage] = [] - private(set) var pipelineResetCount = 0 - private(set) var pipelineChannelUpdates: [ChannelID] = [] + private(set) var pipelineFlushCount = 0 private(set) var refreshedChannels: [ChannelID?] = [] private(set) var publicSystemMessages: [String] = [] var pendingGeohashSystemMessages: [String] = [] private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = [] - private(set) var synchronizedGeohashes: [String] = [] - func resetPublicMessagePipeline() { pipelineResetCount += 1 } - func updatePublicMessagePipelineChannel(_ channel: ChannelID) { pipelineChannelUpdates.append(channel) } + func flushPublicMessagePipeline() { pipelineFlushCount += 1 } func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) } @@ -68,8 +65,6 @@ private final class MockChatNostrContext: ChatNostrContext { return true } - func synchronizePublicConversationStore(forGeohash geohash: String) { synchronizedGeohashes.append(geohash) } - // Inbound public messages private(set) var handledPublicMessages: [BitchatMessage] = [] private(set) var mentionCheckedMessageIDs: [String] = [] @@ -441,9 +436,8 @@ struct ChatNostrCoordinatorContextTests { coordinator.subscriptions.switchLocationChannel(to: .mesh) - #expect(context.pipelineResetCount == 1) + #expect(context.pipelineFlushCount == 1) #expect(context.activeChannel == .mesh) - #expect(context.pipelineChannelUpdates == [.mesh]) #expect(context.clearProcessedNostrEventsCount == 1) #expect(context.refreshedChannels == [.mesh]) #expect(context.refreshTimerStopCount == 1) @@ -578,7 +572,6 @@ struct GeoPresenceTrackerTests { await drainMainQueue() #expect(context.appendedGeohashMessages.isEmpty) #expect(context.lastGeoNotificationAt["9q8yy"] == recent) - #expect(context.synchronizedGeohashes.isEmpty) } @Test @MainActor @@ -605,7 +598,7 @@ struct GeoPresenceTrackerTests { #expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy")) // Cooldown elapsed: the geohash is re-stamped and the append is - // attempted (and rejected as a duplicate, so no store sync either). + // attempted (and rejected as a duplicate, so no notification either). let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1) context.lastGeoNotificationAt["9q8yy"] = stale tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) @@ -614,7 +607,6 @@ struct GeoPresenceTrackerTests { let stamped = try #require(context.lastGeoNotificationAt["9q8yy"]) #expect(stamped > stale) #expect(context.appendedGeohashMessages.count == 1) - #expect(context.synchronizedGeohashes.isEmpty) } @Test @MainActor func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws { @@ -661,10 +653,9 @@ struct GeoPresenceTrackerTests { coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first) await drainMainQueue() - // Sampled message recorded, store synced, and notification posted. + // Sampled message recorded in the store and notification posted. #expect(context.appendedGeohashMessages.map(\.message.id) == [first.id]) #expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4))) - #expect(context.synchronizedGeohashes == ["u4pruyd"]) #expect(context.geohashActivityNotifications.count == 1) #expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd") #expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash") diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index fcb61d8f..6ed7c6da 100644 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -49,21 +49,19 @@ private final class MockChatOutgoingContext: ChatOutgoingContext { } // Public timeline (local echo) - private(set) var appendedTimelineMessages: [(message: BitchatMessage, channel: ChannelID)] = [] - private(set) var refreshedChannels: [ChannelID?] = [] - private(set) var trimMessagesIfNeededCount = 0 + private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] private(set) var systemMessages: [String] = [] func parseMentions(from content: String) -> [String] { content.contains("@bob") ? ["bob"] : [] } - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { - appendedTimelineMessages.append((message, channel)) + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { + appendedPublicMessages.append((message, conversationID)) + return true } - func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } - func trimMessagesIfNeeded() { trimMessagesIfNeededCount += 1 } func addSystemMessage(_ content: String) { systemMessages.append(content) } // Content dedup @@ -129,7 +127,7 @@ struct ChatOutgoingCoordinatorContextTests { await drainMainActorTasks() #expect(context.handledCommands == ["/who all"]) - #expect(context.appendedTimelineMessages.isEmpty) + #expect(context.appendedPublicMessages.isEmpty) #expect(context.sentMeshMessages.isEmpty) } @@ -153,7 +151,7 @@ struct ChatOutgoingCoordinatorContextTests { coordinator.sendMessage("dropped") await drainMainActorTasks() #expect(context.sentPrivateMessages.count == 1) - #expect(context.appendedTimelineMessages.isEmpty) + #expect(context.appendedPublicMessages.isEmpty) } @Test @MainActor @@ -165,16 +163,14 @@ struct ChatOutgoingCoordinatorContextTests { await drainMainActorTasks() // Local echo uses the trimmed content, own nickname/peer ID, mentions. - #expect(context.appendedTimelineMessages.count == 1) - let echo = context.appendedTimelineMessages[0] + #expect(context.appendedPublicMessages.count == 1) + let echo = context.appendedPublicMessages[0] #expect(echo.message.content == "hello @bob") #expect(echo.message.sender == "me") #expect(echo.message.senderPeerID == context.myPeerID) #expect(echo.message.mentions == ["bob"]) - #expect(echo.channel == .mesh) - #expect(context.refreshedChannels == [.mesh]) + #expect(echo.conversationID == .mesh) #expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"]) - #expect(context.trimMessagesIfNeededCount == 1) // The mesh send carries the original (untrimmed) content and reuses // the echo's message ID and timestamp; activity is stamped for "mesh". @@ -200,8 +196,9 @@ struct ChatOutgoingCoordinatorContextTests { // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and // the signed event's ID; the send context targets the same channel. - #expect(context.appendedTimelineMessages.count == 1) - let echo = context.appendedTimelineMessages[0].message + #expect(context.appendedPublicMessages.count == 1) + let echo = context.appendedPublicMessages[0].message + #expect(context.appendedPublicMessages[0].conversationID == .geohash("u4pruydq")) #expect(echo.sender == "me#2222") #expect(context.recordedActivityKeys == ["geo:u4pruydq"]) #expect(context.sentGeohashContexts.count == 1) @@ -215,7 +212,7 @@ struct ChatOutgoingCoordinatorContextTests { coordinator.sendMessage("doomed") await drainMainActorTasks() #expect(context.systemMessages.count == 1) - #expect(context.appendedTimelineMessages.count == 1) + #expect(context.appendedPublicMessages.count == 1) #expect(context.sentGeohashContexts.count == 1) } } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 55b40079..9bcd2b93 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -25,15 +25,13 @@ import BitFoundation /// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`. @MainActor private final class MockChatPublicConversationContext: ChatPublicConversationContext { - // Channel & visible timeline state - var messages: [BitchatMessage] = [] + // Channel state var activeChannel: ChannelID = .mesh var currentGeohash: String? var nickname = "me" var myPeerID = PeerID(str: "0011223344556677") private(set) var isBatchingPublic = false private(set) var notifyUIChangedCount = 0 - private(set) var trimMessagesCount = 0 func setPublicBatching(_ isBatching: Bool) { isBatchingPublic = isBatching @@ -43,92 +41,59 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon notifyUIChangedCount += 1 } - func trimMessagesIfNeeded() { - trimMessagesCount += 1 - } - - // Public timeline store - var meshTimeline: [BitchatMessage] = [] - var geoTimelines: [String: [BitchatMessage]] = [:] + // Public conversation store (single-writer intents) + var conversations: [ConversationID: [BitchatMessage]] = [:] private(set) var queuedGeohashSystemMessages: [String] = [] - func timelineMessages(for channel: ChannelID) -> [BitchatMessage] { - switch channel { - case .mesh: return meshTimeline - case .location(let channel): return geoTimelines[channel.geohash] ?? [] - } + func publicMessages(in conversationID: ConversationID) -> [BitchatMessage] { + conversations[conversationID] ?? [] } - func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { - switch channel { - case .mesh: meshTimeline.append(message) - case .location(let channel): geoTimelines[channel.geohash, default: []].append(message) - } - } - - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - if geoTimelines[geohash]?.contains(where: { $0.id == message.id }) == true { + @discardableResult + func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool { + guard conversations[conversationID]?.contains(where: { $0.id == message.id }) != true else { return false } - geoTimelines[geohash, default: []].append(message) + conversations[conversationID, default: []].append(message) return true } - func removeTimelineMessage(withID id: String) -> BitchatMessage? { - if let index = meshTimeline.firstIndex(where: { $0.id == id }) { - return meshTimeline.remove(at: index) - } - for (geohash, timeline) in geoTimelines { - guard let index = timeline.firstIndex(where: { $0.id == id }) else { continue } + @discardableResult + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + appendPublicMessage(message, to: .geohash(geohash.lowercased())) + } + + func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool { + conversations[conversationID]?.contains(where: { $0.id == messageID }) == true + } + + @discardableResult + func removePublicMessage(withID messageID: String) -> BitchatMessage? { + for (conversationID, timeline) in conversations { + guard let index = timeline.firstIndex(where: { $0.id == messageID }) else { continue } var updated = timeline let removed = updated.remove(at: index) - geoTimelines[geohash] = updated + conversations[conversationID] = updated return removed } return nil } - func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { - geoTimelines[geohash]?.removeAll(where: predicate) + func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) { + conversations[.geohash(geohash.lowercased())]?.removeAll(where: predicate) } - func clearTimeline(for channel: ChannelID) { - switch channel { - case .mesh: meshTimeline.removeAll() - case .location(let channel): geoTimelines[channel.geohash] = [] - } - } + private(set) var clearedConversations: [ConversationID] = [] - func timelineGeohashKeys() -> [String] { - Array(geoTimelines.keys) + func clearPublicConversation(_ conversationID: ConversationID) { + clearedConversations.append(conversationID) + conversations[conversationID] = [] } func queueGeohashSystemMessage(_ content: String) { queuedGeohashSystemMessages.append(content) } - // Conversation stores - private(set) var conversationActiveChannels: [ChannelID] = [] - private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = [] - private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = [] - private(set) var selectionStoreSyncCount = 0 - - func setConversationActiveChannel(_ channel: ChannelID) { - conversationActiveChannels.append(channel) - } - - func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { - replacedChannelMessages.append((channelID, messages.map(\.id))) - } - - func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { - replacedConversationMessages.append((conversationID, messages.map(\.id))) - } - - func synchronizeConversationSelectionStore() { - selectionStoreSyncCount += 1 - } - // Private chats var privateChats: [PeerID: [BitchatMessage]] = [:] var unreadPrivateMessages: Set = [] @@ -222,7 +187,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon var blockedMessageIDs: Set = [] var rateLimitAllowed = true private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = [] - private(set) var enqueuedMessageIDs: [String] = [] + private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = [] + var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) } var stablePeerIDs: [PeerID: PeerID] = [:] func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { @@ -238,8 +204,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon return rateLimitAllowed } - func enqueuePublicMessage(_ message: BitchatMessage) { - enqueuedMessageIDs.append(message.id) + func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { + enqueuedMessages.append((message.id, conversationID)) } func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { @@ -309,24 +275,24 @@ private func makePublicMessage( struct ChatPublicConversationCoordinatorContextTests { @Test @MainActor - func handlePublicMessage_meshMessage_appendsSyncsStoreAndEnqueues() async { + func handlePublicMessage_meshMessage_enqueuesForBatchedStoreCommit() async { let context = MockChatPublicConversationContext() let coordinator = ChatPublicConversationCoordinator(context: context) let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh") coordinator.handlePublicMessage(message) - #expect(context.meshTimeline.map(\.id) == ["mesh-msg-1"]) - #expect(context.replacedChannelMessages.count == 1) - #expect(context.replacedChannelMessages.first?.channel == .mesh) - #expect(context.replacedChannelMessages.first?.messageIDs == ["mesh-msg-1"]) + // Visible-channel arrival: buffered for the batched pipeline flush + // (which commits to the store), not appended directly. #expect(context.rateLimitChecks.count == 1) #expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011") #expect(context.rateLimitChecks.first?.contentKey == "hello mesh") - #expect(context.enqueuedMessageIDs == ["mesh-msg-1"]) + #expect(context.enqueuedMessages.map(\.messageID) == ["mesh-msg-1"]) + #expect(context.enqueuedMessages.first?.conversationID == .mesh) + #expect(context.publicMessages(in: .mesh).isEmpty) - // Already visible in the timeline: stored again, but not re-enqueued. - context.messages = [message] + // Already committed to the store: not re-enqueued. + context.appendPublicMessage(message, to: .mesh) coordinator.handlePublicMessage(message) #expect(context.enqueuedMessageIDs == ["mesh-msg-1"]) } @@ -340,14 +306,14 @@ struct ChatPublicConversationCoordinatorContextTests { context.blockedMessageIDs = ["blocked-msg"] coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg")) #expect(context.rateLimitChecks.isEmpty) - #expect(context.meshTimeline.isEmpty) + #expect(context.publicMessages(in: .mesh).isEmpty) #expect(context.enqueuedMessageIDs.isEmpty) // Rate limited: consulted, then dropped before storage. context.rateLimitAllowed = false coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg")) #expect(context.rateLimitChecks.count == 1) - #expect(context.meshTimeline.isEmpty) + #expect(context.publicMessages(in: .mesh).isEmpty) #expect(context.enqueuedMessageIDs.isEmpty) } @@ -364,16 +330,15 @@ struct ChatPublicConversationCoordinatorContextTests { senderPeerID: PeerID(nostr: senderHex) ) - // On mesh channel: stored in the geohash timeline but not enqueued. + // On mesh channel: a background-channel arrival lands in the geohash + // conversation immediately, with no pipeline batching. context.activeChannel = .mesh coordinator.handlePublicMessage(geoMessage) - #expect(context.geoTimelines[geohash]?.map(\.id) == ["geo-msg-1"]) - #expect(context.replacedConversationMessages.count == 1) - #expect(context.replacedConversationMessages.first?.conversation == .geohash(geohash)) - #expect(context.meshTimeline.isEmpty) + #expect(context.publicMessages(in: .geohash(geohash)).map(\.id) == ["geo-msg-1"]) + #expect(context.publicMessages(in: .mesh).isEmpty) #expect(context.enqueuedMessageIDs.isEmpty) - // On the matching location channel: enqueued for display. + // On the matching location channel: enqueued for the batched flush. context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash)) let second = makePublicMessage( id: "geo-msg-2", @@ -381,7 +346,8 @@ struct ChatPublicConversationCoordinatorContextTests { senderPeerID: PeerID(nostr: senderHex) ) coordinator.handlePublicMessage(second) - #expect(context.enqueuedMessageIDs == ["geo-msg-2"]) + #expect(context.enqueuedMessages.map(\.messageID) == ["geo-msg-2"]) + #expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash)) } @Test @MainActor @@ -396,8 +362,7 @@ struct ChatPublicConversationCoordinatorContextTests { context.currentGeohash = geohash context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash)) - context.geoTimelines[geohash] = [geoMessage] - context.messages = [geoMessage] + context.conversations[.geohash(geohash)] = [geoMessage] context.nostrKeyMapping = [senderPeerID: hex, convKey: hex] context.privateChats[convKey] = [geoMessage] context.unreadPrivateMessages = [convKey] @@ -406,13 +371,14 @@ struct ChatPublicConversationCoordinatorContextTests { #expect(context.blockedNostrPubkeys.contains(hex)) #expect(context.removedGeoParticipants == [hex]) - #expect(context.geoTimelines[geohash]?.isEmpty == true) #expect(context.privateChats[convKey] == nil) #expect(context.unreadPrivateMessages.isEmpty) #expect(context.nostrKeyMapping.isEmpty) - // The blocked user's visible message is gone; a system notice was added. - #expect(!context.messages.contains(where: { $0.id == "geo-bad-1" })) - #expect(context.messages.last?.sender == "system") + // The blocked user's message is purged from the geohash conversation + // (the visible timeline is the same conversation now); a system + // notice was appended to the active conversation. + #expect(!context.publicMessages(in: .geohash(geohash)).contains(where: { $0.id == "geo-bad-1" })) + #expect(context.publicMessages(in: .geohash(geohash)).last?.sender == "system") coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd") #expect(!context.blockedNostrPubkeys.contains(hex)) @@ -424,36 +390,27 @@ struct ChatPublicConversationCoordinatorContextTests { let coordinator = ChatPublicConversationCoordinator(context: context) let peerID = PeerID(str: "0102030405060708") let message = makePublicMessage(id: "doomed-msg") - context.messages = [message] - context.meshTimeline = [message] + context.conversations[.mesh] = [message] context.privateChats[peerID] = [message] coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true) - #expect(context.messages.isEmpty) - #expect(context.meshTimeline.isEmpty) + #expect(context.publicMessages(in: .mesh).isEmpty) #expect(context.privateChats[peerID] == nil) #expect(context.cleanedUpFileMessageIDs == ["doomed-msg"]) #expect(context.notifyUIChangedCount == 1) - // Timeline removal triggers a full conversation-store resync. - #expect(context.replacedChannelMessages.contains(where: { $0.channel == .mesh && $0.messageIDs.isEmpty })) } @Test @MainActor - func addPublicSystemMessage_appendsRefreshesAndRecordsContentKey() async { + func addPublicSystemMessage_appendsToActiveConversationAndRecordsContentKey() async { let context = MockChatPublicConversationContext() let coordinator = ChatPublicConversationCoordinator(context: context) coordinator.addPublicSystemMessage("Tor Ready") - #expect(context.meshTimeline.count == 1) - #expect(context.meshTimeline.first?.sender == "system") - // refreshVisibleMessages mirrors the timeline into the visible list. - #expect(context.messages.map(\.id) == context.meshTimeline.map(\.id)) + #expect(context.publicMessages(in: .mesh).count == 1) + #expect(context.publicMessages(in: .mesh).first?.sender == "system") #expect(context.recordedContentKeys.map(\.key) == ["tor ready"]) - #expect(context.trimMessagesCount == 1) - #expect(context.notifyUIChangedCount == 1) - #expect(context.conversationActiveChannels == [.mesh]) // On mesh, geohash-only system messages are queued for the next geo visit. coordinator.addGeohashOnlySystemMessage("geo notice") @@ -477,22 +434,20 @@ struct ChatPublicConversationCoordinatorContextTests { let coordinator = ChatPublicConversationCoordinator(context: context) let pipeline = PublicMessagePipeline() let message = makePublicMessage(id: "pipeline-msg") - context.messages = [message] context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42) - #expect(coordinator.pipelineCurrentMessages(pipeline).map(\.id) == ["pipeline-msg"]) #expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello") #expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42)) - coordinator.pipeline(pipeline, setMessages: []) - #expect(context.messages.isEmpty) + // Commit lands in the store via the append intent; a duplicate ID + // reports `false` (the store's dedup contract). + #expect(coordinator.pipeline(pipeline, commit: message, to: .mesh)) + #expect(context.publicMessages(in: .mesh).map(\.id) == ["pipeline-msg"]) + #expect(!coordinator.pipeline(pipeline, commit: message, to: .mesh)) coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7)) #expect(context.recordedContentKeys.map(\.key) == ["key-2"]) - coordinator.pipelineTrimMessages(pipeline) - #expect(context.trimMessagesCount == 1) - coordinator.pipelinePrewarmMessage(pipeline, message: message) #expect(context.prewarmedMessageIDs == ["pipeline-msg"]) diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index d176b249..ecebdae3 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -233,7 +233,7 @@ struct ChatViewModelDeliveryStatusTests { isPrivate: false, deliveryStatus: .sending ) - viewModel.messages.append(message) + viewModel.seedPublicMessages([message]) // Action: update to .sent viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent) @@ -253,7 +253,7 @@ struct ChatViewModelDeliveryStatusTests { let firstPeerID = PeerID(str: "0102030405060708") let secondPeerID = PeerID(str: "1112131415161718") - viewModel.messages = [ + viewModel.seedPublicMessages([ BitchatMessage( id: messageID, sender: viewModel.nickname, @@ -264,7 +264,7 @@ struct ChatViewModelDeliveryStatusTests { senderPeerID: transport.myPeerID, deliveryStatus: .sent ) - ] + ]) viewModel.seedPrivateChat([ BitchatMessage( id: messageID, diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift index fcb3f13f..d576106d 100644 --- a/bitchatTests/ChatViewModelRefactoringTests.swift +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -138,7 +138,7 @@ struct ChatViewModelRefactoringTests { // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { - viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" }) + viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" }) }, timeout: TestConstants.defaultTimeout ) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 7b442ed5..ecba96d0 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -438,7 +438,7 @@ struct ChatViewModelReceivingTests { ) let found = await TestHelpers.waitUntil({ - viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" } + viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" } }, timeout: TestConstants.defaultTimeout) #expect(found) @@ -709,10 +709,12 @@ struct ChatViewModelPublicConversationTests { let (viewModel, _) = makeTestableViewModel() viewModel.addPublicSystemMessage("system refresh test") - viewModel.messages.removeAll() viewModel.refreshVisibleMessages(from: .mesh) + // The system message lives in the mesh conversation itself, so the + // derived `messages` view still surfaces it after a refresh. #expect(viewModel.messages.last?.content == "system refresh test") + #expect(viewModel.publicMessages(for: .mesh).last?.content == "system refresh test") } @Test @MainActor @@ -726,6 +728,18 @@ struct ChatViewModelPublicConversationTests { viewModel.refreshVisibleMessages(from: .mesh) #expect(viewModel.messages.isEmpty) + #expect(viewModel.publicMessages(for: .mesh).isEmpty) + } + + @Test @MainActor + func queuedGeohashSystemMessages_drainOnce() async { + let (viewModel, _) = makeTestableViewModel() + + viewModel.queueGeohashSystemMessage("first") + viewModel.queueGeohashSystemMessage("second") + + #expect(viewModel.drainPendingGeohashSystemMessages() == ["first", "second"]) + #expect(viewModel.drainPendingGeohashSystemMessages().isEmpty) } } @@ -999,7 +1013,7 @@ struct ChatViewModelPanicTests { // Set up some state transport.connectedPeers.insert(PeerID(str: "PEER1")) - viewModel.messages = [ + viewModel.seedPublicMessages([ BitchatMessage( id: "panic-1", sender: "Tester", @@ -1007,7 +1021,7 @@ struct ChatViewModelPanicTests { timestamp: Date(), isRelay: false ) - ] + ]) viewModel.seedPrivateChat([ BitchatMessage( id: "pm-1", diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index 4a805134..8535cc57 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -468,4 +468,98 @@ struct ConversationStoreTests { store.append(makeMessage(id: "m3", timestamp: 3), to: b) #expect(bWillChangeCount > 0) } + + // MARK: - Public timelines (mesh/geohash, ex-PublicTimelineStore behavior) + + @Test("geohash conversations are separated by geohash and from mesh") + @MainActor + func geohashConversationSeparation() { + let store = ConversationStore() + store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh) + store.append(makeMessage(id: "geo-a-1", timestamp: 2), to: .geohash("u4pruyd")) + store.append(makeMessage(id: "geo-b-1", timestamp: 3), to: .geohash("9q8yy")) + + #expect(store.conversation(for: .mesh).messages.map(\.id) == ["mesh-1"]) + #expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["geo-a-1"]) + #expect(store.conversation(for: .geohash("9q8yy")).messages.map(\.id) == ["geo-b-1"]) + } + + @Test("geohash append dedups by ID and reports duplicates") + @MainActor + func geohashAppendIfAbsentContract() { + let store = ConversationStore() + let message = makeMessage(id: "geo-1", timestamp: 1) + + #expect(store.append(message, to: .geohash("u4pruyd"))) + #expect(!store.append(message, to: .geohash("u4pruyd"))) + // The same ID is still fresh in a different geohash. + #expect(store.append(message, to: .geohash("9q8yy"))) + } + + @Test("removePublicMessage searches mesh and geohash conversations only") + @MainActor + func removePublicMessageSearchesPublicConversations() { + let store = ConversationStore() + let direct = makeDirectConversationID("aa") + store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh) + store.append(makeMessage(id: "geo-1", timestamp: 2), to: .geohash("u4pruyd")) + store.append(makeMessage(id: "dm-1", timestamp: 3, isPrivate: true), to: direct) + + #expect(store.removePublicMessage(withID: "geo-1")?.id == "geo-1") + #expect(store.conversation(for: .geohash("u4pruyd")).messages.isEmpty) + + #expect(store.removePublicMessage(withID: "mesh-1")?.id == "mesh-1") + #expect(store.conversation(for: .mesh).messages.isEmpty) + + // Direct conversations are never touched. + #expect(store.removePublicMessage(withID: "dm-1") == nil) + #expect(store.conversation(for: direct).messages.map(\.id) == ["dm-1"]) + } + + @Test("removeMessages(from:where:) purges matches and emits per removal") + @MainActor + func removeMessagesByPredicate() { + let store = ConversationStore() + let id = ConversationID.geohash("u4pruyd") + store.append(makeMessage(id: "keep-1", timestamp: 1), to: id) + store.append(makeMessage(id: "drop-1", timestamp: 2, content: "purge me"), to: id) + store.append(makeMessage(id: "drop-2", timestamp: 3, content: "purge me"), to: id) + store.append(makeMessage(id: "keep-2", timestamp: 4), to: id) + + var removedIDs: [String] = [] + var cancellables = Set() + store.changes + .sink { change in + if case .messageRemoved(_, let messageID) = change { + removedIDs.append(messageID) + } + } + .store(in: &cancellables) + + store.removeMessages(from: id, where: { $0.content == "purge me" }) + + #expect(store.conversation(for: id).messages.map(\.id) == ["keep-1", "keep-2"]) + #expect(removedIDs == ["drop-1", "drop-2"]) + // The ID index survives the purge: dedup and removal still work. + #expect(!store.append(makeMessage(id: "keep-2", timestamp: 4), to: id)) + #expect(store.removeMessage(withID: "keep-1", from: id) != nil) + #expect(store.conversation(for: id).messages.map(\.id) == ["keep-2"]) + } + + @Test("trimmed public message IDs can return after falling off the cap") + @MainActor + func trimmedMessageIDsCanReturn() { + let store = ConversationStore() + let id = ConversationID.geohash("u4pruyd") + let conversation = store.conversation(for: id) + let first = makeMessage(id: "one", timestamp: 1) + + store.append(first, to: id) + for index in 0.. [String] { [] } func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true } - func synchronizePublicConversationStore(forGeohash geohash: String) {} private(set) var handledPublicMessageCount = 0 func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 } diff --git a/bitchatTests/PublicMessagePipelineTests.swift b/bitchatTests/PublicMessagePipelineTests.swift index 9bf0a5cf..6d1458da 100644 --- a/bitchatTests/PublicMessagePipelineTests.swift +++ b/bitchatTests/PublicMessagePipelineTests.swift @@ -2,7 +2,10 @@ // PublicMessagePipelineTests.swift // bitchatTests // -// Tests for PublicMessagePipeline ordering and deduplication. +// Tests for PublicMessagePipeline batching, content dedup, and per-message +// conversation routing. Ordering and ID dedup live in the ConversationStore +// the flush commits into (the old late-insert threshold is gone; see +// ConversationStoreTests for ordered-insert coverage). // import Testing @@ -13,14 +16,15 @@ import BitFoundation @MainActor private final class TestPipelineDelegate: PublicMessagePipelineDelegate { private let dedupService = MessageDeduplicationService() - var messages: [BitchatMessage] = [] + /// Commits in arrival-at-commit order, per conversation. + private(set) var committed: [(message: BitchatMessage, conversationID: ConversationID)] = [] + /// Message IDs the commit rejects (simulates the store's ID dedup). + var rejectedMessageIDs: Set = [] + private(set) var recordedContentKeys: [String] = [] + private(set) var batchingStates: [Bool] = [] - func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { - messages - } - - func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { - self.messages = messages + func messages(in conversationID: ConversationID) -> [BitchatMessage] { + committed.filter { $0.conversationID == conversationID }.map(\.message) } func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { @@ -33,19 +37,37 @@ private final class TestPipelineDelegate: PublicMessagePipelineDelegate { func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { dedupService.recordContentKey(key, timestamp: timestamp) + recordedContentKeys.append(key) } - func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {} + func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool { + guard !rejectedMessageIDs.contains(message.id) else { return false } + committed.append((message, conversationID)) + return true + } func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {} - func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {} + func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { + batchingStates.append(isBatching) + } +} + +@MainActor +private func makeMessage(id: String, content: String, timestamp: Date) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "A", + content: content, + timestamp: timestamp, + isRelay: false + ) } struct PublicMessagePipelineTests { @Test @MainActor - func flush_sortsByTimestamp() async { + func flush_commitsInTimestampOrder() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate @@ -53,26 +75,13 @@ struct PublicMessagePipelineTests { let earlier = Date().addingTimeInterval(-10) let later = Date() - let messageA = BitchatMessage( - id: "a", - sender: "A", - content: "Later", - timestamp: later, - isRelay: false - ) - let messageB = BitchatMessage( - id: "b", - sender: "A", - content: "Earlier", - timestamp: earlier, - isRelay: false - ) - - pipeline.enqueue(messageA) - pipeline.enqueue(messageB) + pipeline.enqueue(makeMessage(id: "a", content: "Later", timestamp: later), to: .mesh) + pipeline.enqueue(makeMessage(id: "b", content: "Earlier", timestamp: earlier), to: .mesh) pipeline.flushIfNeeded() - #expect(delegate.messages.map { $0.id } == ["b", "a"]) + #expect(delegate.messages(in: .mesh).map { $0.id } == ["b", "a"]) + // Batching state wrapped the flush. + #expect(delegate.batchingStates == [true, false]) } @Test @MainActor @@ -82,86 +91,41 @@ struct PublicMessagePipelineTests { pipeline.delegate = delegate let now = Date() - let messageA = BitchatMessage( - id: "a", - sender: "A", - content: "Same", - timestamp: now, - isRelay: false - ) - let messageB = BitchatMessage( - id: "b", - sender: "A", - content: "Same", - timestamp: now.addingTimeInterval(0.2), - isRelay: false - ) - - pipeline.enqueue(messageA) - pipeline.enqueue(messageB) + pipeline.enqueue(makeMessage(id: "a", content: "Same", timestamp: now), to: .mesh) + pipeline.enqueue(makeMessage(id: "b", content: "Same", timestamp: now.addingTimeInterval(0.2)), to: .mesh) pipeline.flushIfNeeded() - #expect(delegate.messages.count == 1) - #expect(delegate.messages.first?.content == "Same") + #expect(delegate.messages(in: .mesh).count == 1) + #expect(delegate.messages(in: .mesh).first?.content == "Same") } @Test @MainActor - func lateInsert_meshAppendsRecentOlderMessage() async { + func flush_routesEachMessageToItsConversation() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate - pipeline.updateActiveChannel(.mesh) let base = Date() - let newer = BitchatMessage( - id: "new", - sender: "A", - content: "New", - timestamp: base, - isRelay: false - ) - let older = BitchatMessage( - id: "old", - sender: "A", - content: "Old", - timestamp: base.addingTimeInterval(-5), - isRelay: false - ) - - delegate.messages = [newer] - pipeline.enqueue(older) + pipeline.enqueue(makeMessage(id: "mesh-1", content: "mesh hello", timestamp: base), to: .mesh) + // A channel switch mid-batch must not misroute already-buffered messages. + pipeline.enqueue(makeMessage(id: "geo-1", content: "geo hello", timestamp: base.addingTimeInterval(1)), to: .geohash("u4pruydq")) pipeline.flushIfNeeded() - #expect(delegate.messages.map { $0.id } == ["new", "old"]) + #expect(delegate.messages(in: .mesh).map { $0.id } == ["mesh-1"]) + #expect(delegate.messages(in: .geohash("u4pruydq")).map { $0.id } == ["geo-1"]) } @Test @MainActor - func lateInsert_locationInsertsByTimestamp() async { + func flush_rejectedCommitDoesNotRecordContentKey() async { let pipeline = PublicMessagePipeline() let delegate = TestPipelineDelegate() pipeline.delegate = delegate - pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq"))) + delegate.rejectedMessageIDs = ["dup"] - let base = Date() - let newer = BitchatMessage( - id: "new", - sender: "A", - content: "New", - timestamp: base, - isRelay: false - ) - let older = BitchatMessage( - id: "old", - sender: "A", - content: "Old", - timestamp: base.addingTimeInterval(-5), - isRelay: false - ) - - delegate.messages = [newer] - pipeline.enqueue(older) + pipeline.enqueue(makeMessage(id: "dup", content: "already stored", timestamp: Date()), to: .mesh) pipeline.flushIfNeeded() - #expect(delegate.messages.map { $0.id } == ["old", "new"]) + #expect(delegate.messages(in: .mesh).isEmpty) + #expect(delegate.recordedContentKeys.isEmpty) } } diff --git a/bitchatTests/PublicTimelineStoreTests.swift b/bitchatTests/PublicTimelineStoreTests.swift deleted file mode 100644 index 17081aca..00000000 --- a/bitchatTests/PublicTimelineStoreTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation -import BitFoundation -import Testing -@testable import bitchat - -@Suite("PublicTimelineStore Tests") -struct PublicTimelineStoreTests { - - @Test("Mesh timeline deduplicates and trims to cap") - func meshTimelineDeduplicatesAndTrims() { - var store = PublicTimelineStore(meshCap: 2, geohashCap: 2) - let first = TestHelpers.createTestMessage(content: "one") - let second = TestHelpers.createTestMessage(content: "two") - let third = TestHelpers.createTestMessage(content: "three") - - store.append(first, to: .mesh) - store.append(second, to: .mesh) - store.append(first, to: .mesh) - store.append(third, to: .mesh) - - let messages = store.messages(for: .mesh) - #expect(messages.map(\.content) == ["two", "three"]) - } - - @Test("Timeline indexes allow trimmed message IDs to return") - func timelineIndexesAllowTrimmedMessageIDsToReturn() { - var store = PublicTimelineStore(meshCap: 2, geohashCap: 2) - let first = timelineMessage(id: "one", content: "one", timestamp: 1) - let second = timelineMessage(id: "two", content: "two", timestamp: 2) - let third = timelineMessage(id: "three", content: "three", timestamp: 3) - - store.append(first, to: .mesh) - store.append(second, to: .mesh) - store.append(third, to: .mesh) - store.append(first, to: .mesh) - - #expect(store.messages(for: .mesh).map(\.content) == ["three", "one"]) - - let geohash = "u4pruydq" - let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let geoFirst = timelineMessage(id: "geo-one", content: "geo one", timestamp: 1) - let geoSecond = timelineMessage(id: "geo-two", content: "geo two", timestamp: 2) - let geoThird = timelineMessage(id: "geo-three", content: "geo three", timestamp: 3) - - let didAppendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash) - let didAppendGeoSecond = store.appendIfAbsent(geoSecond, toGeohash: geohash) - let didAppendGeoThird = store.appendIfAbsent(geoThird, toGeohash: geohash) - let didReappendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash) - - #expect(didAppendGeoFirst) - #expect(didAppendGeoSecond) - #expect(didAppendGeoThird) - #expect(didReappendGeoFirst) - #expect(store.messages(for: channel).map(\.content) == ["geo one", "geo three"]) - } - - @Test("Geohash appendIfAbsent remove and clear work together") - func geohashStoreSupportsAppendRemoveAndClear() { - var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) - let geohash = "u4pruydq" - let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let first = TestHelpers.createTestMessage(content: "geo one") - let second = TestHelpers.createTestMessage(content: "geo two") - - let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash) - let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash) - - #expect(didAppendFirst) - #expect(!didAppendDuplicate) - store.append(second, toGeohash: geohash) - let removed = store.removeMessage(withID: first.id) - - #expect(removed?.id == first.id) - #expect(store.messages(for: channel).map(\.content) == ["geo two"]) - - store.clear(channel: channel) - #expect(store.messages(for: channel).isEmpty) - } - - @Test("Mutate geohash updates stored messages in place") - func mutateGeohashAppliesTransformation() { - var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) - let geohash = "u4pruydq" - let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let first = TestHelpers.createTestMessage(content: "geo one") - - store.append(first, toGeohash: geohash) - store.mutateGeohash(geohash) { timeline in - timeline.append(TestHelpers.createTestMessage(content: "geo two")) - } - - #expect(store.messages(for: channel).map(\.content) == ["geo one", "geo two"]) - } - - @Test("Queued geohash system messages drain once") - func pendingGeohashSystemMessagesDrainOnce() { - var store = PublicTimelineStore(meshCap: 1, geohashCap: 1) - - store.queueGeohashSystemMessage("first") - store.queueGeohashSystemMessage("second") - - #expect(store.drainPendingGeohashSystemMessages() == ["first", "second"]) - #expect(store.drainPendingGeohashSystemMessages().isEmpty) - } - - private func timelineMessage(id: String, content: String, timestamp: TimeInterval) -> BitchatMessage { - BitchatMessage( - id: id, - sender: "alice", - content: content, - timestamp: Date(timeIntervalSince1970: timestamp), - isRelay: false - ) - } -} diff --git a/bitchatTests/TestUtilities/TestHelpers.swift b/bitchatTests/TestUtilities/TestHelpers.swift index c09c60c5..21894e91 100644 --- a/bitchatTests/TestUtilities/TestHelpers.swift +++ b/bitchatTests/TestUtilities/TestHelpers.swift @@ -153,6 +153,24 @@ extension ChatViewModel { } } + /// Test-only replacement for the deleted `messages` setter: seeds a + /// public channel's conversation through the single-writer + /// `ConversationStore` intents (upsert keeps re-seeding with updated + /// copies working the way the old array assignment did). + @MainActor + func seedPublicMessages(_ messages: [BitchatMessage], for channel: ChannelID = .mesh) { + for message in messages { + conversations.upsertByID(message, in: ConversationID(channelID: channel)) + } + } + + /// Test-only replacement for `messages.removeAll()`: empties a public + /// channel's conversation. + @MainActor + func clearPublicMessages(for channel: ChannelID = .mesh) { + conversations.clear(ConversationID(channelID: channel)) + } + /// Test-only: drops every private chat and unread flag. @MainActor func clearAllPrivateChats() { diff --git a/docs/CONVERSATION-STORE-DESIGN.md b/docs/CONVERSATION-STORE-DESIGN.md index 7b01e7e9..7f63342e 100644 --- a/docs/CONVERSATION-STORE-DESIGN.md +++ b/docs/CONVERSATION-STORE-DESIGN.md @@ -1,8 +1,9 @@ # Conversation Store: Single Source of Truth -**Status:** Approved design, not yet implemented. Baselines recorded in +**Status:** Steps 1–3 implemented (additive store, private cutover, public +cutover; `PublicTimelineStore` deleted). Baselines recorded in `bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`, -`pipeline.publicIngest`). +`pipeline.publicIngest`, `store.append`). --- From 7fb1f4a21993e971f3498e83f4a899e775b692cc Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 13:26:00 +0200 Subject: [PATCH 5/7] Route delivery status through the store; delete the location index ConversationStore maintains an exact messageID -> Set map at every mutation point (append/upsert/remove/migrate/trim/clear), so delivery updates are ID-only lookups that fan out to mirrored ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119 lines: the positional location index, its growth-detection/rebuild machinery, and the duplicate no-downgrade check are deleted - the rule now lives in exactly one place. The middle-insertion regression tests are rewritten against the store since stale positional locations are structurally impossible now. delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged. Co-Authored-By: Claude Fable 5 --- bitchat/App/ConversationStore.swift | 171 +++++++++-- .../ViewModels/ChatDeliveryCoordinator.swift | 278 +++--------------- .../ChatViewModelDeliveryStatusTests.swift | 197 ++++++++----- bitchatTests/ConversationStoreTests.swift | 155 ++++++++++ .../PerformanceBaselineTests.swift | 141 +++++---- docs/CONVERSATION-STORE-DESIGN.md | 8 +- 6 files changed, 546 insertions(+), 404 deletions(-) diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 4e752b9b..f7fff03a 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -64,10 +64,25 @@ final class Conversation: ObservableObject, Identifiable { return messages[index] } + /// All message IDs currently in this conversation (unordered). + var messageIDs: Dictionary.Keys { + indexByMessageID.keys + } + // MARK: Store-internal mutations + /// Result of an ordered insert. `trimmedMessageIDs` reports messages + /// evicted by the cap so the store can keep its message-ID → + /// conversation map exact. + fileprivate struct InsertResult { + let inserted: Bool + let trimmedMessageIDs: [String] + + static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: []) + } + fileprivate enum UpsertOutcome { - case appended + case appended(trimmedMessageIDs: [String]) case updated } @@ -75,9 +90,9 @@ final class Conversation: ObservableObject, Identifiable { /// Fast path appends when the timestamp is >= the current tail; /// otherwise a binary search finds the upper-bound insertion point so /// arrival order is preserved among equal timestamps. - /// Returns `false` if a message with the same ID already exists. - fileprivate func insert(_ message: BitchatMessage) -> Bool { - guard indexByMessageID[message.id] == nil else { return false } + /// Reports `inserted: false` if a message with the same ID already exists. + fileprivate func insert(_ message: BitchatMessage) -> InsertResult { + guard indexByMessageID[message.id] == nil else { return .duplicate } if let last = messages.last, message.timestamp < last.timestamp { let index = insertionIndex(for: message.timestamp) @@ -88,8 +103,7 @@ final class Conversation: ObservableObject, Identifiable { indexByMessageID[message.id] = messages.count - 1 } - trimIfNeeded() - return true + return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded()) } /// Replace-or-append by message ID. An existing message keeps its @@ -100,14 +114,14 @@ final class Conversation: ObservableObject, Identifiable { messages[index] = message return .updated } - _ = insert(message) - return .appended + let result = insert(message) + return .appended(trimmedMessageIDs: result.trimmedMessageIDs) } /// Applies a delivery status keyed by message ID, honoring the - /// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`): - /// equal statuses are skipped, and `.read` is never downgraded to - /// `.delivered` or `.sent`. + /// no-downgrade rule (the SOLE enforcement point — every delivery + /// update flows through the store): equal statuses are skipped, and + /// `.read` is never downgraded to `.delivered` or `.sent`. /// Returns `true` when the status was applied. fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { guard let index = indexByMessageID[messageID] else { return false } @@ -197,14 +211,17 @@ final class Conversation: ObservableObject, Identifiable { } } - private func trimIfNeeded() { - guard messages.count > cap else { return } + /// Trims oldest messages over the cap; returns the trimmed message IDs. + private func trimIfNeeded() -> [String] { + guard messages.count > cap else { return [] } let overflow = messages.count - cap - for message in messages.prefix(overflow) { - indexByMessageID.removeValue(forKey: message.id) + let trimmedIDs = messages.prefix(overflow).map(\.id) + for id in trimmedIDs { + indexByMessageID.removeValue(forKey: id) } messages.removeFirst(overflow) reindex(from: 0) + return trimmedIDs } } @@ -242,6 +259,19 @@ final class ConversationStore: ObservableObject { private(set) var conversationsByID: [ConversationID: Conversation] = [:] + /// Store-level message-ID → conversation-membership map for ID-only + /// lookups (delivery receipts arrive with a message ID, not a + /// conversation). Maintained incrementally at every mutation point — + /// all mutation is centralized in the intent API below, so the map is + /// exact, never scanned or rebuilt. + /// + /// The value is a `Set` because a private message can legitimately live + /// in TWO direct conversations: step 2's raw per-peer keying mirrors a + /// message into both the stable-key and ephemeral-peer chats + /// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both + /// copies. + private var conversationIDsByMessageID: [String: Set] = [:] + let changes = PassthroughSubject() // MARK: Intent API @@ -264,7 +294,10 @@ final class ConversationStore: ObservableObject { @discardableResult func append(_ message: BitchatMessage, to id: ConversationID) -> Bool { let conversation = conversation(for: id) - guard conversation.insert(message) else { return false } + let result = conversation.insert(message) + guard result.inserted else { return false } + registerMessageID(message.id, in: id) + unregisterMessageIDs(result.trimmedMessageIDs, from: id) changes.send(.appended(id, message)) return true } @@ -273,7 +306,9 @@ final class ConversationStore: ObservableObject { func upsertByID(_ message: BitchatMessage, in id: ConversationID) { let conversation = conversation(for: id) switch conversation.upsert(message) { - case .appended: + case .appended(let trimmedMessageIDs): + registerMessageID(message.id, in: id) + unregisterMessageIDs(trimmedMessageIDs, from: id) changes.send(.appended(id, message)) case .updated: changes.send(.updated(id, messageID: message.id)) @@ -293,6 +328,44 @@ final class ConversationStore: ObservableObject { return true } + /// Applies a delivery status to EVERY conversation containing + /// `messageID` (ID-only — delivery receipts don't know conversations; + /// mirrored private copies live in two direct chats). Returns `false` + /// when the message is unknown or no copy changed (equal status or + /// downgrade — read beats delivered beats sent). + /// + /// `BitchatMessage` is a reference type, so mirrored copies sharing one + /// instance are updated by the first apply; subsequent conversations + /// skip as already-equal (state stays correct everywhere, the + /// `.statusChanged` event fires for the conversation that applied). + @discardableResult + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { + guard let ids = conversationIDsByMessageID[messageID] else { return false } + var applied = false + for id in ids where setDeliveryStatus(status, forMessageID: messageID, in: id) { + applied = true + } + return applied + } + + /// Current delivery status of `messageID` in whichever conversation + /// holds it (mirrored copies share status — see `setDeliveryStatus`). + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { + guard let ids = conversationIDsByMessageID[messageID] else { return nil } + for id in ids { + if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus { + return status + } + } + return nil + } + + /// Every conversation currently containing `messageID` (empty when the + /// message is unknown). + func conversationIDs(forMessageID messageID: String) -> Set { + conversationIDsByMessageID[messageID] ?? [] + } + func markRead(_ id: ConversationID) { guard unreadConversations.contains(id) else { return } unreadConversations.remove(id) @@ -329,7 +402,13 @@ final class ConversationStore: ObservableObject { let destinationConversation = conversation(for: destination) for message in sourceConversation.messages { - _ = destinationConversation.insert(message) + let result = destinationConversation.insert(message) + guard result.inserted else { continue } + registerMessageID(message.id, in: destination) + unregisterMessageIDs(result.trimmedMessageIDs, from: destination) + } + for messageID in sourceConversation.messageIDs { + unregisterMessageID(messageID, from: source) } let wasUnread = unreadConversations.contains(source) @@ -359,6 +438,7 @@ final class ConversationStore: ObservableObject { let removed = conversation.remove(messageID: messageID) else { return nil } + unregisterMessageID(messageID, from: id) changes.send(.messageRemoved(id, messageID: messageID)) return removed } @@ -368,7 +448,9 @@ final class ConversationStore: ObservableObject { /// conversation is consistent. No-op for unknown conversations. func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) { guard let conversation = conversationsByID[id] else { return } - for messageID in conversation.removeAll(where: predicate) { + let removedIDs = conversation.removeAll(where: predicate) + unregisterMessageIDs(removedIDs, from: id) + for messageID in removedIDs { changes.send(.messageRemoved(id, messageID: messageID)) } } @@ -377,6 +459,9 @@ final class ConversationStore: ObservableObject { /// its unread/selection state) alive. func clear(_ id: ConversationID) { guard let conversation = conversationsByID[id] else { return } + for messageID in conversation.messageIDs { + unregisterMessageID(messageID, from: id) + } conversation.clearMessages() changes.send(.cleared(id)) } @@ -384,7 +469,10 @@ final class ConversationStore: ObservableObject { /// Removes a conversation entirely, including unread state; clears the /// selection if it pointed at the removed conversation. func removeConversation(_ id: ConversationID) { - guard conversationsByID.removeValue(forKey: id) != nil else { return } + guard let conversation = conversationsByID.removeValue(forKey: id) else { return } + for messageID in conversation.messageIDs { + unregisterMessageID(messageID, from: id) + } conversationIDs.removeAll { $0 == id } unreadConversations.remove(id) if selectedConversationID == id { @@ -400,6 +488,7 @@ final class ConversationStore: ObservableObject { conversationsByID.removeAll() conversationIDs.removeAll() unreadConversations.removeAll() + conversationIDsByMessageID.removeAll() if selectedConversationID != nil { selectedConversationID = nil } @@ -411,6 +500,26 @@ final class ConversationStore: ObservableObject { // MARK: Internals + private func registerMessageID(_ messageID: String, in id: ConversationID) { + conversationIDsByMessageID[messageID, default: []].insert(id) + } + + private func unregisterMessageID(_ messageID: String, from id: ConversationID) { + guard var ids = conversationIDsByMessageID[messageID] else { return } + ids.remove(id) + if ids.isEmpty { + conversationIDsByMessageID.removeValue(forKey: messageID) + } else { + conversationIDsByMessageID[messageID] = ids + } + } + + private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) { + for messageID in messageIDs { + unregisterMessageID(messageID, from: id) + } + } + private static func cap(for id: ConversationID) -> Int { switch id { case .mesh: @@ -471,13 +580,23 @@ extension ConversationStore { } /// `true` when any direct conversation contains a message with `messageID` - /// (O(1) per conversation via the incremental ID index). + /// (O(1) via the store-level message-ID → conversation map). func directConversationsContainMessage(withID messageID: String) -> Bool { + conversationIDs(forMessageID: messageID).contains { id in + if case .direct = id { return true } + return false + } + } + + /// Message IDs across all direct conversations (read-receipt pruning + /// keeps only receipts whose messages still exist). + func directMessageIDs() -> Set { + var messageIDs = Set() for (id, conversation) in conversationsByID { guard case .direct = id else { continue } - if conversation.containsMessage(withID: messageID) { return true } + messageIDs.formUnion(conversation.messageIDs) } - return false + return messageIDs } /// Removes every direct conversation (panic clear). @@ -501,12 +620,10 @@ extension ConversationStore { /// message, if any. @discardableResult func removePublicMessage(withID messageID: String) -> BitchatMessage? { - for (id, conversation) in conversationsByID { + for id in conversationIDs(forMessageID: messageID) { switch id { case .mesh, .geohash: - if conversation.containsMessage(withID: messageID) { - return removeMessage(withID: messageID, from: id) - } + return removeMessage(withID: messageID, from: id) case .direct: continue } diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index 8acf7bec..f2fc13c0 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -12,18 +12,19 @@ import Foundation /// coordinators off their `unowned let viewModel: ChatViewModel` back-refs. @MainActor protocol ChatDeliveryContext: AnyObject { - /// Get-only derived views of the `ConversationStore`. `BitchatMessage` - /// is a reference type, so the public-timeline status patch below writes - /// through the shared message objects; step 4 replaces this with - /// `setDeliveryStatus(for:in:)` store intents. - var messages: [BitchatMessage] { get } - var privateChats: [PeerID: [BitchatMessage]] { get } var isStartupPhase: Bool { get } - /// Applies a delivery status to a private message by ID (single-writer - /// store intent; full delivery migration is step 4). Returns `false` - /// when the message is unknown or the update would downgrade the status. + /// Applies a delivery status to every copy of the message across + /// conversations (`ConversationStore` intent, ID-only: the store's + /// message-ID → conversation map resolves which conversations hold the + /// message, including mirrored ephemeral/stable private copies). The + /// no-downgrade rule is enforced in the store. Returns `false` when the + /// message is unknown or no copy changed. @discardableResult - func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool + /// Current delivery status of the message in whichever conversation holds it. + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? + /// Message IDs across all direct conversations (read-receipt pruning). + func privateMessageIDs() -> Set /// Drops every recorded read receipt whose message ID is not in `validMessageIDs`. /// Returns the number of receipts removed. (Single mutation path for the /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) @@ -35,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject { } extension ChatViewModel: ChatDeliveryContext { + @discardableResult + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { + conversations.setDeliveryStatus(status, forMessageID: messageID) + } + + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { + conversations.deliveryStatus(forMessageID: messageID) + } + + func privateMessageIDs() -> Set { + conversations.directMessageIDs() + } + func notifyUIChanged() { objectWillChange.send() } @@ -44,14 +58,12 @@ extension ChatViewModel: ChatDeliveryContext { } } +/// Thin mapper from delivery events (read receipts, transport delivery +/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt +/// retention cleanup. The store's message-ID → conversation map replaces the +/// positional `messageLocationIndex` this coordinator used to maintain. final class ChatDeliveryCoordinator { private unowned let context: any ChatDeliveryContext - private var messageLocationIndex: [String: Set] = [:] - private var indexedPublicMessageCount = 0 - private var indexedPublicTailMessageID: String? - private var indexedPrivateMessageCounts: [PeerID: Int] = [:] - private var indexedPrivateTailMessageIDs: [PeerID: String] = [:] - private var hasBuiltMessageLocationIndex = false init(context: any ChatDeliveryContext) { self.context = context @@ -59,15 +71,9 @@ final class ChatDeliveryCoordinator { @MainActor func cleanupOldReadReceipts() { - guard !context.isStartupPhase, !context.privateChats.isEmpty else { - return - } - - let validMessageIDs = Set( - context.privateChats.values.flatMap { messages in - messages.map(\.id) - } - ) + guard !context.isStartupPhase else { return } + let validMessageIDs = context.privateMessageIDs() + guard !validMessageIDs.isEmpty else { return } let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs) if removedCount > 0 { @@ -90,9 +96,7 @@ final class ChatDeliveryCoordinator { @MainActor func deliveryStatus(for messageID: String) -> DeliveryStatus? { - withValidLocations(for: messageID) { locations in - locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first - } + context.deliveryStatus(forMessageID: messageID) } @MainActor @@ -106,222 +110,10 @@ final class ChatDeliveryCoordinator { break } - var didUpdateStatus = false - let locations = withValidLocations(for: messageID) { $0 } - guard !locations.isEmpty else { return false } - - for location in locations { - guard case .publicTimeline(let index) = location, - index < context.messages.count, - context.messages[index].id == messageID else { - continue - } - - let currentStatus = context.messages[index].deliveryStatus - if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - context.messages[index].deliveryStatus = status - didUpdateStatus = true - } - } - - for location in locations { - guard case .privateChat(let peerID, let index) = location, - let chatMessages = context.privateChats[peerID], - index < chatMessages.count, - chatMessages[index].id == messageID else { - continue - } - - let currentStatus = chatMessages[index].deliveryStatus - guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } - - if context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID) { - didUpdateStatus = true - } - } - - if didUpdateStatus { - context.notifyUIChanged() - } - - return didUpdateStatus - } -} - -private extension ChatDeliveryCoordinator { - enum MessageLocation: Hashable { - case publicTimeline(index: Int) - case privateChat(peerID: PeerID, index: Int) - } - - func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { - guard let currentStatus else { return false } - if currentStatus == newStatus { return true } - - switch (currentStatus, newStatus) { - case (.read, .delivered), (.read, .sent): - return true - default: + guard context.setDeliveryStatus(status, forMessageID: messageID) else { return false } - } - - @MainActor - func withValidLocations( - for messageID: String, - _ body: (Set) -> T - ) -> T { - let didRebuildIndex = refreshMessageLocationIndexForGrowth() - - if let locations = messageLocationIndex[messageID], - locations.allSatisfy({ isLocation($0, validFor: messageID) }) { - return body(locations) - } - - guard !didRebuildIndex else { - return body(messageLocationIndex[messageID] ?? []) - } - - if messageLocationIndex[messageID] == nil { - return body([]) - } - - rebuildMessageLocationIndex() - return body(messageLocationIndex[messageID] ?? []) - } - - @MainActor - func deliveryStatus(at location: MessageLocation) -> DeliveryStatus? { - switch location { - case .publicTimeline(let index): - guard index < context.messages.count else { return nil } - return context.messages[index].deliveryStatus - case .privateChat(let peerID, let index): - guard let messages = context.privateChats[peerID], - index < messages.count else { - return nil - } - return messages[index].deliveryStatus - } - } - - @MainActor - func isLocation(_ location: MessageLocation, validFor messageID: String) -> Bool { - switch location { - case .publicTimeline(let index): - return index < context.messages.count - && context.messages[index].id == messageID - case .privateChat(let peerID, let index): - guard let messages = context.privateChats[peerID], - index < messages.count else { - return false - } - return messages[index].id == messageID - } - } - - @MainActor - @discardableResult - func refreshMessageLocationIndexForGrowth() -> Bool { - guard hasBuiltMessageLocationIndex else { - rebuildMessageLocationIndex() - return true - } - - if context.messages.count < indexedPublicMessageCount { - rebuildMessageLocationIndex() - return true - } - - if context.messages.count == indexedPublicMessageCount, - context.messages.last?.id != indexedPublicTailMessageID { - rebuildMessageLocationIndex() - return true - } - - if context.messages.count > indexedPublicMessageCount { - // Growth is only a pure append if the previously indexed tail kept - // its position; a middle insertion (out-of-order timestamp arrival) - // shifts it and invalidates every indexed location after the - // insertion point. - if indexedPublicMessageCount > 0, - context.messages[indexedPublicMessageCount - 1].id != indexedPublicTailMessageID { - rebuildMessageLocationIndex() - return true - } - for index in indexedPublicMessageCount.. indexedCount else { continue } - // Same append-only check as the public timeline above. - if indexedCount > 0, - messages[indexedCount - 1].id != indexedPrivateTailMessageIDs[peerID] { - rebuildMessageLocationIndex() - return true - } - for index in indexedCount.. = [] var isStartupPhase = false private(set) var notifyUIChangedCount = 0 private(set) var markedDeliveredMessageIDs: [String] = [] + @discardableResult + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { + store.setDeliveryStatus(status, forMessageID: messageID) + } + + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { + store.deliveryStatus(forMessageID: messageID) + } + + func privateMessageIDs() -> Set { + store.directMessageIDs() + } + func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { let oldCount = sentReadReceipts.count sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) @@ -406,29 +421,19 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { func markMessageDelivered(_ messageID: String) { markedDeliveredMessageIDs.append(messageID) } - - @discardableResult - func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { - guard var chat = privateChats[peerID], - let index = chat.firstIndex(where: { $0.id == messageID }) else { - return false - } - if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) { - return false - } - chat[index].deliveryStatus = status - privateChats[peerID] = chat - return true - } } @MainActor -private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage { +private func makePrivateMessage( + id: String, + status: DeliveryStatus, + timestamp: Date = Date() +) -> BitchatMessage { BitchatMessage( id: id, sender: "me", content: "Test message", - timestamp: Date(), + timestamp: timestamp, isRelay: false, isPrivate: true, recipientNickname: "Peer", @@ -437,10 +442,28 @@ private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMe ) } +@MainActor +private func makePublicMessage( + id: String, + status: DeliveryStatus, + timestamp: Date = Date() +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "me", + content: "Public message", + timestamp: timestamp, + isRelay: false, + isPrivate: false, + deliveryStatus: status + ) +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` — -/// the exemplar for the narrow-dependency coordinator pattern. +/// the exemplar for the narrow-dependency coordinator pattern. State is +/// seeded into and asserted against the mock's `ConversationStore`. struct ChatDeliveryCoordinatorContextTests { @Test @MainActor @@ -449,7 +472,7 @@ struct ChatDeliveryCoordinatorContextTests { let coordinator = ChatDeliveryCoordinator(context: context) let peerID = PeerID(str: "0102030405060708") let messageID = "mock-msg-1" - context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .sent)] + context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(peerID)) let didUpdate = coordinator.updateMessageDeliveryStatus( messageID, @@ -457,7 +480,7 @@ struct ChatDeliveryCoordinatorContextTests { ) #expect(didUpdate) - #expect(isDelivered(context.privateChats[peerID]?.first?.deliveryStatus)) + #expect(isDelivered(context.store.conversation(for: .directPeer(peerID)).message(withID: messageID)?.deliveryStatus)) #expect(context.notifyUIChangedCount == 1) #expect(context.markedDeliveredMessageIDs == [messageID]) } @@ -468,13 +491,16 @@ struct ChatDeliveryCoordinatorContextTests { let coordinator = ChatDeliveryCoordinator(context: context) let peerID = PeerID(str: "0102030405060708") let messageID = "mock-msg-2" - context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date()))] + context.store.append( + makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date())), + to: .directPeer(peerID) + ) coordinator.didReceiveReadReceipt( ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer") ) - #expect(isRead(context.privateChats[peerID]?.first?.deliveryStatus)) + #expect(isRead(coordinator.deliveryStatus(for: messageID))) #expect(context.notifyUIChangedCount == 1) #expect(context.markedDeliveredMessageIDs == [messageID]) } @@ -483,22 +509,12 @@ struct ChatDeliveryCoordinatorContextTests { func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async { let context = MockChatDeliveryContext() let coordinator = ChatDeliveryCoordinator(context: context) - context.messages = [ - BitchatMessage( - id: "public-mock-1", - sender: "me", - content: "Public message", - timestamp: Date(), - isRelay: false, - isPrivate: false, - deliveryStatus: .sending - ) - ] + context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh) // .sent is not a confirmed receipt — must not reach markMessageDelivered. let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent) #expect(didUpdate) - #expect(isSent(context.messages.first?.deliveryStatus)) + #expect(isSent(context.store.conversation(for: .mesh).message(withID: "public-mock-1")?.deliveryStatus)) #expect(context.markedDeliveredMessageIDs.isEmpty) #expect(context.notifyUIChangedCount == 1) @@ -508,57 +524,98 @@ struct ChatDeliveryCoordinatorContextTests { #expect(context.notifyUIChangedCount == 1) } + // The old positional `messageLocationIndex` could go stale when a late + // arrival was inserted mid-array (count grew but indexed locations + // shifted). The store's per-conversation ID index is reindexed inside the + // same mutation, so staleness is structurally impossible — these tests + // pin the equivalent behavior through the new path: after out-of-order + // insertion, updates keyed by ID still land on the right messages. + @Test @MainActor - func middleInsertedMessage_isFoundAfterIndexWasBuilt() async { + func middleInsertedMessage_isStillUpdatableByID() async { let context = MockChatDeliveryContext() let coordinator = ChatDeliveryCoordinator(context: context) - let makePublic = { (id: String) in - BitchatMessage( - id: id, - sender: "me", - content: "Public message", - timestamp: Date(), - isRelay: false, - isPrivate: false, - deliveryStatus: .sending - ) - } - context.messages = [makePublic("public-a"), makePublic("public-b")] + context.store.append( + makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)), + to: .mesh + ) + context.store.append( + makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)), + to: .mesh + ) - // Build the incremental index. #expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent)) - // Out-of-order arrival: PublicMessagePipeline inserts by timestamp, - // so the count grows while the tail ID stays the same. - context.messages.insert(makePublic("public-mid"), at: 1) + // Out-of-order arrival: the store inserts by timestamp, shifting the + // tail's position. + context.store.append( + makePublicMessage(id: "public-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)), + to: .mesh + ) + let mesh = context.store.conversation(for: .mesh) + #expect(mesh.messages.map(\.id) == ["public-a", "public-mid", "public-b"]) - // The inserted message must be locatable, and the shifted tail must - // not be updated through a stale index entry. + // Both the inserted message and the shifted tail stay updatable. #expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent)) - #expect(isSent(context.messages[1].deliveryStatus)) + #expect(isSent(mesh.message(withID: "public-mid")?.deliveryStatus)) #expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent)) - #expect(isSent(context.messages[2].deliveryStatus)) + #expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus)) } @Test @MainActor - func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async { + func middleInsertedPrivateMessage_isStillUpdatableByID() async { let context = MockChatDeliveryContext() let coordinator = ChatDeliveryCoordinator(context: context) let peerID = PeerID(str: "0102030405060708") - context.privateChats[peerID] = [ - makePrivateMessage(id: "pm-a", status: .sending), - makePrivateMessage(id: "pm-b", status: .sending) - ] + let conversationID = ConversationID.directPeer(peerID) + context.store.append( + makePrivateMessage(id: "pm-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)), + to: conversationID + ) + context.store.append( + makePrivateMessage(id: "pm-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)), + to: conversationID + ) #expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent)) - // Timestamp re-sort (sanitizeChat) can place a late arrival mid-array. - context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1) + // A late arrival with an older timestamp lands mid-array. + context.store.append( + makePrivateMessage(id: "pm-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)), + to: conversationID + ) + let chat = context.store.conversation(for: conversationID) + #expect(chat.messages.map(\.id) == ["pm-a", "pm-mid", "pm-b"]) #expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent)) - #expect(isSent(context.privateChats[peerID]?[1].deliveryStatus)) + #expect(isSent(chat.message(withID: "pm-mid")?.deliveryStatus)) #expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent)) - #expect(isSent(context.privateChats[peerID]?[2].deliveryStatus)) + #expect(isSent(chat.message(withID: "pm-b")?.deliveryStatus)) + } + + @Test @MainActor + func mirroredPrivateCopies_bothReceiveDeliveryUpdate() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let stablePeerID = PeerID(str: String(repeating: "ab", count: 32)) + let ephemeralPeerID = PeerID(str: "0102030405060708") + let messageID = "mirrored-mock-1" + + // Step 2's keying mirrors a private message into both the stable-key + // and ephemeral-peer conversations (distinct copies here to prove + // per-conversation application, not shared-reference aliasing). + context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(stablePeerID)) + context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(ephemeralPeerID)) + + let didUpdate = coordinator.updateMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()) + ) + + #expect(didUpdate) + #expect(isDelivered(context.store.conversation(for: .directPeer(stablePeerID)).message(withID: messageID)?.deliveryStatus)) + #expect(isDelivered(context.store.conversation(for: .directPeer(ephemeralPeerID)).message(withID: messageID)?.deliveryStatus)) + #expect(context.markedDeliveredMessageIDs == [messageID]) } @Test @MainActor @@ -566,7 +623,7 @@ struct ChatDeliveryCoordinatorContextTests { let context = MockChatDeliveryContext() let coordinator = ChatDeliveryCoordinator(context: context) let peerID = PeerID(str: "0102030405060708") - context.privateChats[peerID] = [makePrivateMessage(id: "keep-receipt", status: .sent)] + context.store.append(makePrivateMessage(id: "keep-receipt", status: .sent), to: .directPeer(peerID)) context.sentReadReceipts = ["keep-receipt", "drop-receipt"] // Startup phase: cleanup must be a no-op. diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index 8535cc57..3cd0f225 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -562,4 +562,159 @@ struct ConversationStoreTests { #expect(!conversation.containsMessage(withID: "one")) #expect(store.append(makeMessage(id: "one", timestamp: 2000), to: id)) } + + // MARK: - Store-level message-ID → conversation map (ID-only delivery) + + @Test("ID map tracks append, upsert, and removal") + @MainActor + func messageIDMapTracksAppendAndRemoval() { + let store = ConversationStore() + let direct = makeDirectConversationID("aa") + + #expect(store.conversationIDs(forMessageID: "m1").isEmpty) + + store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh) + #expect(store.conversationIDs(forMessageID: "m1") == [.mesh]) + + // Upsert-as-append registers; upsert-in-place does not duplicate. + store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true), in: direct) + store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: direct) + #expect(store.conversationIDs(forMessageID: "d1") == [direct]) + + store.removeMessage(withID: "m1", from: .mesh) + #expect(store.conversationIDs(forMessageID: "m1").isEmpty) + + store.removeMessages(from: direct, where: { $0.id == "d1" }) + #expect(store.conversationIDs(forMessageID: "d1").isEmpty) + } + + @Test("ID map handles multi-conversation membership (mirrored private copies)") + @MainActor + func messageIDMapHandlesMirroredCopies() { + let store = ConversationStore() + let stable = makeDirectConversationID("stable") + let ephemeral = makeDirectConversationID("ephemeral") + + // Step 2's keying mirrors one private message into the stable-key + // AND ephemeral-peer conversations (distinct copies here to prove + // per-conversation bookkeeping). + store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: stable) + store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: ephemeral) + #expect(store.conversationIDs(forMessageID: "dm-1") == [stable, ephemeral]) + + // An ID-only delivery update reaches BOTH copies. + #expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1")) + for id in [stable, ephemeral] { + guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else { + Issue.record("expected .delivered in \(id)") + return + } + } + + // Removing one copy keeps the other resolvable. + store.removeMessage(withID: "dm-1", from: ephemeral) + #expect(store.conversationIDs(forMessageID: "dm-1") == [stable]) + } + + @Test("ID-only setDeliveryStatus enforces no-downgrade and unknown IDs") + @MainActor + func idOnlyDeliveryStatusRules() { + let store = ConversationStore() + let direct = makeDirectConversationID("aa") + store.append( + makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .read(by: "bob", at: Date())), + to: direct + ) + + // Unknown message. + #expect(!store.setDeliveryStatus(.sent, forMessageID: "missing")) + #expect(store.deliveryStatus(forMessageID: "missing") == nil) + + // Downgrade from .read is refused; status is readable by ID alone. + #expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1")) + guard case .read = store.deliveryStatus(forMessageID: "dm-1") else { + Issue.record("expected .read to survive the downgrade attempt") + return + } + } + + @Test("ID map follows migration between conversations") + @MainActor + func messageIDMapFollowsMigration() { + let store = ConversationStore() + let source = makeDirectConversationID("ephemeral") + let destination = makeDirectConversationID("stable") + store.append(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true), to: source) + store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: source) + // Already present in the destination: migration dedups, and the map + // must not retain a stale source membership. + store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: destination) + + store.migrateConversation(from: source, to: destination) + + #expect(store.conversationIDs(forMessageID: "dm-1") == [destination]) + #expect(store.conversationIDs(forMessageID: "dm-2") == [destination]) + // Delivery updates keep flowing after the handoff. + #expect(store.setDeliveryStatus(.sent, forMessageID: "dm-1")) + } + + @Test("ID map drops trimmed messages") + @MainActor + func messageIDMapDropsTrimmedMessages() { + let store = ConversationStore() + let id = ConversationID.geohash("u4pruyd") + let conversation = store.conversation(for: id) + store.append(makeMessage(id: "first", timestamp: 1), to: id) + for index in 0.. delivered so every call - /// performs a real update (never the skip path). + /// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the + /// `ConversationStore`'s message-ID → conversation map: 2000 public + /// (split mesh + geohash to stay under the per-conversation cap) + 50x40 + /// private messages, 500 status updates per pass. Statuses alternate + /// sent <-> delivered so every call performs a real update (never the + /// skip path). func testDeliveryStatusIncrementalUpdates() { let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) let coordinator = ChatDeliveryCoordinator(context: context) - // Warm the index so the measure block exercises the incremental path. - XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent)) - - // 250 public + 250 private IDs spread across the corpus. - var targetIDs: [String] = [] - for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) } - let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id } - for (offset, peer) in peerIDs.enumerated() where offset < 25 { - guard let chat = context.privateChats[peer] else { continue } - for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) } - } + let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) XCTAssertEqual(targetIDs.count, 500) let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) @@ -235,46 +227,38 @@ final class PerformanceBaselineTests: XCTestCase { reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates") } - // MARK: - 4b. Delivery location index (rebuild path) + // MARK: - 4b. Delivery status updates against the store directly - /// The expensive path: a non-append insertion (out-of-order arrival at - /// the head of the timeline) invalidates the index, so the next status - /// update triggers a full rebuild over ~4000 message locations. - func testDeliveryStatusIndexRebuild() { + /// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same + /// scale as 4a, without the coordinator/context wrapping — the store-side + /// cost of an ID-only delivery update (map lookup + per-conversation + /// ID-index apply + change emission). Replaces the deleted + /// `delivery.indexRebuild` benchmark: the positional location index and + /// its rebuild path no longer exist; the store's ID indexes are + /// maintained inside each mutation, so there is no rebuild to measure. + func testDeliveryStatusStoreUpdates() { let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) - let coordinator = ChatDeliveryCoordinator(context: context) - // Warm the index before the first insertion invalidates it. - XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent)) + let store = context.store + let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) + XCTAssertEqual(targetIDs.count, 500) - // Pre-built head insertions, one consumed per measure pass. - let insertions: [BitchatMessage] = (0..<32).map { i in - BitchatMessage( - id: "insert-\(i)", - sender: "alice", - content: "out-of-order arrival \(i)", - timestamp: Date(timeIntervalSince1970: 1_690_000_000), - isRelay: false - ) - } - let probeID = context.messages[1000].id let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) - var pass = 0 var toggle = false var samples: [TimeInterval] = [] measure { - precondition(pass < insertions.count, "add more pre-built insertions") - context.messages.insert(insertions[pass], at: 0) - pass += 1 toggle.toggle() let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent let start = Date() - let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status) + var updated = 0 + for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) { + updated += 1 + } samples.append(Date().timeIntervalSince(start)) - XCTAssertTrue(updated) + XCTAssertEqual(updated, targetIDs.count) } - reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds") + reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates") } // MARK: - 5. Message formatting @@ -638,50 +622,63 @@ private final class PerfNostrContext: ChatNostrContext { // MARK: - Mock ChatDeliveryContext +/// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the +/// coordinator is a thin mapper onto store intents, so the measured cost is +/// the store's ID-map delivery path). @MainActor private final class PerfDeliveryContext: ChatDeliveryContext { - var messages: [BitchatMessage] = [] - var privateChats: [PeerID: [BitchatMessage]] = [:] + let store = ConversationStore() var sentReadReceipts: Set = [] var isStartupPhase: Bool { false } + private var publicIDs: [String] = [] + private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = [] + func notifyUIChanged() {} func markMessageDelivered(_ messageID: String) {} + @discardableResult + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { + store.setDeliveryStatus(status, forMessageID: messageID) + } + + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { + store.deliveryStatus(forMessageID: messageID) + } + + func privateMessageIDs() -> Set { + store.directMessageIDs() + } + func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { let oldCount = sentReadReceipts.count sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) return oldCount - sentReadReceipts.count } - @discardableResult - func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool { - guard var chat = privateChats[peerID], - let index = chat.firstIndex(where: { $0.id == messageID }) else { - return false - } - chat[index].deliveryStatus = status - privateChats[peerID] = chat - return true - } - - /// 2000 public + `peerCount` x `messagesPerPeer` private messages with - /// deterministic IDs and timestamps. + /// `publicCount` public messages (split evenly between mesh and one + /// geohash conversation so the corpus stays under the per-conversation + /// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into + /// the store with deterministic IDs and timestamps. static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext { let context = PerfDeliveryContext() let base = Date(timeIntervalSince1970: 1_700_000_000) - context.messages = (0.. [String] { + var targetIDs: [String] = [] + let publicStride = max(1, publicIDs.count / publicTargets) + for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets { + targetIDs.append(publicIDs[i]) + } + var privateCount = 0 + outer: for (_, messageIDs) in privateIDsByPeer { + for i in stride(from: 0, to: messageIDs.count, by: 4) { + guard privateCount < privateTargets else { break outer } + targetIDs.append(messageIDs[i]) + privateCount += 1 + } + } + return targetIDs + } } // MARK: - End-to-end pipeline fixture diff --git a/docs/CONVERSATION-STORE-DESIGN.md b/docs/CONVERSATION-STORE-DESIGN.md index 7f63342e..aa34b406 100644 --- a/docs/CONVERSATION-STORE-DESIGN.md +++ b/docs/CONVERSATION-STORE-DESIGN.md @@ -1,9 +1,11 @@ # Conversation Store: Single Source of Truth -**Status:** Steps 1–3 implemented (additive store, private cutover, public -cutover; `PublicTimelineStore` deleted). Baselines recorded in +**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`). +`pipeline.publicIngest`, `store.append`, `delivery.incrementalUpdate`, +`delivery.storeUpdate`). --- From 38331e62f15b6596c9c93b9da012eb81da30a287 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 13:57:12 +0200 Subject: [PATCH 6/7] Cut views over to ConversationStore; delete legacy store, bridge, resolver Feature models observe per-conversation objects directly: PublicChatModel forwards the active Conversation's objectWillChange, PrivateInboxModel republishes only for the selected peer's conversation - background appends no longer invalidate foreground views. LegacyConversationStore, the coalescing bridge, and IdentityResolver are deleted (resolver canonicalization proved display-invisible: nothing enumerates direct conversations, lookups are by exact peer ID, and raw keying is strictly more robust - documented as a design deviation). Selection state moves into the store. ChatViewModel.messages/privateChats survive as derived read views for coordinators that genuinely need them; hot paths use a new store-direct privateMessages(for:) witness. Final numbers vs pre-migration baselines: pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x) pipeline.publicIngest 6.8k -> 13.7k msg/s (2.0x) delivery updates 38k -> 117-133k/s Co-Authored-By: Claude Fable 5 --- bitchat/App/AppArchitecture.swift | 313 +-------------- bitchat/App/AppRuntime.swift | 25 +- bitchat/App/ConversationStore.swift | 86 ++-- bitchat/App/ConversationUIModel.swift | 10 +- .../App/LegacyConversationStoreBridge.swift | 160 -------- bitchat/App/PeerListModel.swift | 8 +- bitchat/App/PrivateConversationModels.swift | 110 ++++-- bitchat/App/PublicChatModel.swift | 70 +++- bitchat/Services/CommandProcessor.swift | 1 - bitchat/Services/PrivateChatManager.swift | 5 +- bitchat/Services/TransportConfig.swift | 3 - .../ViewModels/ChatLifecycleCoordinator.swift | 16 +- .../ChatMediaTransferCoordinator.swift | 3 +- .../ChatPeerIdentityCoordinator.swift | 17 +- .../ViewModels/ChatPeerListCoordinator.swift | 10 +- .../ChatPrivateConversationCoordinator.swift | 15 +- .../ChatPublicConversationCoordinator.swift | 11 +- .../ChatTransportEventCoordinator.swift | 21 +- bitchat/ViewModels/ChatViewModel.swift | 113 ++---- .../ChatViewModelBootstrapper.swift | 11 +- .../Components/CommandSuggestionsView.swift | 2 +- .../Views/Components/TextMessageView.swift | 4 +- bitchatTests/AppArchitectureTests.swift | 369 ++++++++---------- ...ChatLifecycleCoordinatorContextTests.swift | 4 + .../ChatPeerListCoordinatorContextTests.swift | 4 + ...ransportEventCoordinatorContextTests.swift | 4 + bitchatTests/ChatViewModelTests.swift | 5 +- bitchatTests/ConversationStoreTests.swift | 5 +- .../LegacyConversationStoreBridgeTests.swift | 246 ------------ .../PerformanceBaselineTests.swift | 49 ++- bitchatTests/ViewSmokeTests.swift | 12 +- docs/CONVERSATION-STORE-DESIGN.md | 44 ++- 32 files changed, 542 insertions(+), 1214 deletions(-) delete mode 100644 bitchat/App/LegacyConversationStoreBridge.swift delete mode 100644 bitchatTests/LegacyConversationStoreBridgeTests.swift 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). From 8289a6d05e44e8508bdd3bfcc48e14b07bd281c1 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 14:09:43 +0200 Subject: [PATCH 7/7] Republish mirrored conversations on shared-instance status changes When a private message is mirrored into stable-key and ephemeral-peer conversations as one shared BitchatMessage instance, the first conversation's status apply mutated the shared object and the second skipped as already-equal - state stayed correct but the mirrored conversation never republished, so a view observing it rendered stale delivery/read status. The ID-only fan-out now republishes and emits .statusChanged for every skipped conversation whose message holds the applied status; genuinely-rejected distinct copies (downgrades) stay untouched, and duplicate acks still publish nothing. Found by Codex review on #1334. Co-Authored-By: Claude Fable 5 --- bitchat/App/ConversationStore.swift | 38 +++++++++++++++++---- bitchatTests/ConversationStoreTests.swift | 40 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index da7a8dee..73b57b34 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -135,6 +135,17 @@ final class Conversation: ObservableObject, Identifiable { return true } + /// Republishes a message without changing state. Used for mirrored + /// copies that share a BitchatMessage instance: the first conversation's + /// status apply mutated the shared object, so this conversation's + /// observers still need an @Published emission to re-render. + @discardableResult + fileprivate func republishMessage(withID messageID: String) -> Bool { + guard let index = indexByMessageID[messageID] else { return false } + messages[index] = messages[index] + return true + } + @discardableResult fileprivate func setUnread(_ unread: Bool) -> Bool { guard isUnread != unread else { return false } @@ -345,17 +356,32 @@ final class ConversationStore: ObservableObject { /// downgrade — read beats delivered beats sent). /// /// `BitchatMessage` is a reference type, so mirrored copies sharing one - /// instance are updated by the first apply; subsequent conversations - /// skip as already-equal (state stays correct everywhere, the - /// `.statusChanged` event fires for the conversation that applied). + /// instance are mutated by the first conversation's apply. The skipped + /// conversations still hold the changed message, so they get an explicit + /// republish and `.statusChanged` event - otherwise a view observing the + /// mirrored conversation would render stale status. Distinct copies whose + /// update was genuinely rejected (downgrade) are left untouched, guarded + /// by status equality. @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { guard let ids = conversationIDsByMessageID[messageID] else { return false } var applied = false - for id in ids where setDeliveryStatus(status, forMessageID: messageID, in: id) { - applied = true + var skipped: [ConversationID] = [] + for id in ids { + if setDeliveryStatus(status, forMessageID: messageID, in: id) { + applied = true + } else { + skipped.append(id) + } } - return applied + guard applied else { return false } + for id in skipped { + guard let conversation = conversationsByID[id], + conversation.message(withID: messageID)?.deliveryStatus == status, + conversation.republishMessage(withID: messageID) else { continue } + changes.send(.statusChanged(id, messageID: messageID, status)) + } + return true } /// Current delivery status of `messageID` in whichever conversation diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index efb03a89..52598db9 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -714,4 +714,44 @@ struct ConversationStoreTests { } } } + + @Test("mirrored conversations both republish and emit on a shared-instance status change") + @MainActor + func sharedInstanceMirroredCopiesBothRepublishOnStatusChange() { + let store = ConversationStore() + let stable = makeDirectConversationID("stable") + let ephemeral = makeDirectConversationID("ephemeral") + let message = makeMessage(id: "dm-2", timestamp: 1, isPrivate: true, deliveryStatus: .sent) + store.upsertByID(message, in: stable) + store.upsertByID(message, in: ephemeral) + + var cancellables = Set() + var publishedIDs: [ConversationID] = [] + for id in [stable, ephemeral] { + store.conversation(for: id).objectWillChange + .sink { publishedIDs.append(id) } + .store(in: &cancellables) + } + var statusChangedIDs: [ConversationID] = [] + store.changes + .sink { change in + if case .statusChanged(let id, "dm-2", _) = change { statusChangedIDs.append(id) } + } + .store(in: &cancellables) + + let read = DeliveryStatus.read(by: "bob", at: Date()) + #expect(store.setDeliveryStatus(read, forMessageID: "dm-2")) + + // The shared instance is mutated once, but a view observing EITHER + // conversation must re-render, and both emit a change event. + #expect(Set(publishedIDs) == Set([stable, ephemeral])) + #expect(Set(statusChangedIDs) == Set([stable, ephemeral])) + + // A duplicate ack applies nowhere and must publish nothing. + publishedIDs.removeAll() + statusChangedIDs.removeAll() + #expect(!store.setDeliveryStatus(read, forMessageID: "dm-2")) + #expect(publishedIDs.isEmpty) + #expect(statusChangedIDs.isEmpty) + } }