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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 13:57:12 +02:00
co-authored by Claude Fable 5
parent 7fb1f4a219
commit 38331e62f1
32 changed files with 542 additions and 1214 deletions
+3 -310
View File
@@ -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 { struct PeerHandle: Sendable, Identifiable {
let id: String let id: String
let routingPeerID: PeerID let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
} }
extension PeerHandle: Equatable { 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<ConversationID> = []
@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<PeerID> {
unreadConversations.reduce(into: Set<PeerID>()) { 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<PeerID>,
identityResolver: IdentityResolver
) {
var liveConversations = Set<ConversationID>()
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
}
}
+9 -16
View File
@@ -14,11 +14,9 @@ import AppKit
final class AppRuntime: ObservableObject { final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel let chatViewModel: ChatViewModel
let events = AppEventStream() let events = AppEventStream()
let conversationStore: LegacyConversationStore /// Single source of truth for conversation message state and selection
/// Single source of truth for conversation message state /// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// (docs/CONVERSATION-STORE-DESIGN.md). The legacy store above keeps /// and `ChatViewModel` observe and mutate it through its intent API.
/// feeding the feature models until step 5, mirrored from this one by
/// `LegacyConversationStoreBridge`.
let conversations: ConversationStore let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore let locationPresenceStore: LocationPresenceStore
@@ -47,13 +45,10 @@ final class AppRuntime: ObservableObject {
idBridge: NostrIdentityBridge = NostrIdentityBridge() idBridge: NostrIdentityBridge = NostrIdentityBridge()
) { ) {
self.idBridge = idBridge self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = LegacyConversationStore()
let conversations = ConversationStore() let conversations = ConversationStore()
let peerIdentityStore = PeerIdentityStore() let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore() let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.conversations = conversations self.conversations = conversations
self.peerIdentityStore = peerIdentityStore self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore self.locationPresenceStore = locationPresenceStore
@@ -61,19 +56,17 @@ final class AppRuntime: ObservableObject {
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain), identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
conversations: conversations, conversations: conversations,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore, peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore, locationPresenceStore: locationPresenceStore,
locationManager: locationManager locationManager: locationManager
) )
self.publicChatModel = PublicChatModel(conversationStore: conversationStore) self.publicChatModel = PublicChatModel(conversations: conversations)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) self.privateInboxModel = PrivateInboxModel(conversations: conversations)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager) self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel( self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
conversationStore: conversationStore, conversations: conversations,
locationChannelsModel: self.locationChannelsModel, locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore peerIdentityStore: peerIdentityStore
) )
@@ -85,11 +78,11 @@ final class AppRuntime: ObservableObject {
self.conversationUIModel = ConversationUIModel( self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel, privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore conversations: conversations
) )
self.peerListModel = PeerListModel( self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
conversationStore: conversationStore, conversations: conversations,
locationChannelsModel: self.locationChannelsModel, locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore, peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore locationPresenceStore: locationPresenceStore
@@ -226,7 +219,7 @@ final class AppRuntime: ObservableObject {
userInfo: [AnyHashable: Any] userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions { ) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID { if conversations.selectedPrivatePeerID == peerID {
return [] return []
} }
return [.banner, .sound] return [.banner, .sound]
+61 -25
View File
@@ -7,9 +7,9 @@
// `ConversationID`; all mutations flow through the store's intent API and // `ConversationID`; all mutations flow through the store's intent API and
// every mutation emits a `ConversationChange` after state is consistent. // every mutation emits a `ConversationChange` after state is consistent.
// //
// During migration the previous replace-based store lives on as // The store also owns conversation selection: the active public channel and
// `LegacyConversationStore` (AppArchitecture.swift) and is deleted in the // the selected private peer (the two UI selection axes) plus the derived
// final migration step. // `selectedConversationID`.
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
@@ -257,6 +257,16 @@ final class ConversationStore: ObservableObject {
@Published private(set) var selectedConversationID: ConversationID? @Published private(set) var selectedConversationID: ConversationID?
@Published private(set) var unreadConversations: Set<ConversationID> = [] @Published private(set) var unreadConversations: Set<ConversationID> = []
// 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] = [:] private(set) var conversationsByID: [ConversationID: Conversation] = [:]
/// Store-level message-ID conversation-membership map for ID-only /// Store-level message-ID conversation-membership map for ID-only
@@ -391,6 +401,32 @@ final class ConversationStore: ObservableObject {
selectedConversationID = id 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 /// Moves all messages from `source` into `destination` (the
/// ephemeralstable peer-ID handoff): dedups by message ID, preserves /// ephemeralstable peer-ID handoff): dedups by message ID, preserves
/// timestamp order, carries unread state over, and hands off the /// timestamp order, carries unread state over, and hands off the
@@ -424,6 +460,13 @@ final class ConversationStore: ObservableObject {
} }
if wasSelected { if wasSelected {
selectedConversationID = destination 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)) 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 { extension ConversationID {
/// Direct-conversation ID keyed by the *raw* routing peer ID. /// Direct-conversation ID keyed by the *raw* routing peer ID.
/// ///
/// Migration step 2 keeps one conversation per `PeerID` exactly the /// Direct conversations are deliberately keyed per `PeerID`, not per
/// buckets the legacy `privateChats` dictionary had so the /// resolved identity: the private-chat coordinators mirror messages into
/// ephemeral/stable mirroring and consolidation coordinators keep their /// both the ephemeral and stable peer's conversations
/// current semantics. Step 5 canonicalizes direct conversations through /// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
/// `IdentityResolver` and this helper goes away. /// 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 { static func directPeer(_ peerID: PeerID) -> ConversationID {
.direct(PeerHandle( .direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
id: "peer:\(peerID.id)",
routingPeerID: peerID,
displayName: nil,
noisePublicKeyHex: nil,
nostrPublicKey: nil
))
} }
} }
extension ConversationStore { extension ConversationStore {
/// All direct conversations' messages keyed by routing peer ID the /// All direct conversations' messages keyed by routing peer ID the
/// compat shape of the legacy `privateChats` dictionary. Values are the /// shape `ChatViewModel.privateChats` exposes to the coordinators.
/// conversations' backing arrays (COW), so building this is /// Values are the conversations' backing arrays (COW), so building this
/// O(#conversations), not O(#messages). /// is O(#conversations), not O(#messages).
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] { func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
messagesByPeerID.reserveCapacity(conversationsByID.count) messagesByPeerID.reserveCapacity(conversationsByID.count)
@@ -568,8 +606,8 @@ extension ConversationStore {
return messagesByPeerID return messagesByPeerID
} }
/// Unread direct conversations as routing peer IDs the compat shape of /// Unread direct conversations as routing peer IDs the shape
/// the legacy `unreadPrivateMessages` set. /// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
func unreadDirectRoutingPeerIDs() -> Set<PeerID> { func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
var peerIDs = Set<PeerID>() var peerIDs = Set<PeerID>()
for id in unreadConversations { 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 { extension ConversationStore {
/// Removes a message by ID from whichever public (mesh/geohash) /// Removes a message by ID from whichever public (mesh/geohash)
/// conversation contains it the compat shape of the legacy /// conversation contains it. Returns the removed message, if any.
/// `PublicTimelineStore.removeMessage(withID:)`. Returns the removed
/// message, if any.
@discardableResult @discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? { func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for id in conversationIDs(forMessageID: messageID) { for id in conversationIDs(forMessageID: messageID) {
+5 -5
View File
@@ -15,19 +15,19 @@ final class ConversationUIModel: ObservableObject {
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel private let privateConversationModel: PrivateConversationModel
private let conversationStore: LegacyConversationStore private let conversations: ConversationStore
private var activeChannel: ChannelID private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
chatViewModel: ChatViewModel, chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel, privateConversationModel: PrivateConversationModel,
conversationStore: LegacyConversationStore conversations: ConversationStore
) { ) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore self.conversations = conversations
self.activeChannel = conversationStore.activeChannel self.activeChannel = conversations.activeChannel
self.currentNickname = chatViewModel.nickname self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete self.showAutocomplete = chatViewModel.showAutocomplete
@@ -151,7 +151,7 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic) .assign(to: &$isBatchingPublic)
conversationStore.$activeChannel conversations.$activeChannel
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] channel in .sink { [weak self] channel in
self?.activeChannel = channel self?.activeChannel = channel
@@ -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 <https://unlicense.org>
//
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<ConversationID> = []
private var pendingFlushTask: Task<Void, Never>?
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
}
}
+4 -4
View File
@@ -37,7 +37,7 @@ final class PeerListModel: ObservableObject {
@Published private(set) var renderID = "" @Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let conversationStore: LegacyConversationStore private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore private let locationPresenceStore: LocationPresenceStore
@@ -45,13 +45,13 @@ final class PeerListModel: ObservableObject {
init( init(
chatViewModel: ChatViewModel, chatViewModel: ChatViewModel,
conversationStore: LegacyConversationStore, conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil, locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil, peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil locationPresenceStore: LocationPresenceStore? = nil
) { ) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.conversationStore = conversationStore self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
@@ -122,7 +122,7 @@ final class PeerListModel: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
conversationStore.$unreadConversations conversations.$unreadConversations
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.refresh() self?.refresh()
+67 -43
View File
@@ -2,69 +2,93 @@ import BitFoundation
import Combine import Combine
import Foundation 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 @MainActor
final class PrivateInboxModel: ObservableObject { final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID? @Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = [] @Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: LegacyConversationStore private let conversations: ConversationStore
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(conversationStore: LegacyConversationStore) { init(conversations: ConversationStore) {
self.conversationStore = conversationStore self.conversations = conversations
self.selectedPeerID = conversations.selectedPrivatePeerID
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
bind() bind()
refreshMessages()
} }
func messages(for peerID: PeerID?) -> [BitchatMessage] { func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] } guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? [] return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
} }
private func bind() { private func bind() {
conversationStore.$selectedPrivatePeerID conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main) .dropFirst()
.sink { [weak self] peerID in .sink { [weak self] peerID in
self?.selectedPeerID = peerID guard let self, self.selectedPeerID != peerID else { return }
self?.refreshMessages() self.selectedPeerID = peerID
} }
.store(in: &cancellables) .store(in: &cancellables)
conversationStore.$unreadConversations conversations.changes
.receive(on: DispatchQueue.main) .sink { [weak self] change in
.sink { [weak self] _ in self?.apply(change)
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
} }
.store(in: &cancellables) .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() { private func apply(_ change: ConversationChange) {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID() switch change {
var peerIDs = Set(nextMessagesByPeerID.keys) case .appended(let id, _),
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs()) .updated(let id, _),
if let selectedPeerID = conversationStore.selectedPrivatePeerID { .statusChanged(let id, _, _),
peerIDs.insert(selectedPeerID) .messageRemoved(let id, _),
} .cleared(let id):
republishIfSelected(id)
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil { case .unreadChanged(let id, _):
nextMessagesByPeerID[peerID] = [] guard isDirect(id) else { return }
} refreshUnreadPeerIDs()
guard messagesByPeerID != nextMessagesByPeerID else { return } case .removed(let id):
messagesByPeerID = nextMessagesByPeerID 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? @Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let conversationStore: LegacyConversationStore private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
chatViewModel: ChatViewModel, chatViewModel: ChatViewModel,
conversationStore: LegacyConversationStore, conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil, locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil peerIdentityStore: PeerIdentityStore? = nil
) { ) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.conversationStore = conversationStore self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID let initialPeerID = conversations.selectedPrivatePeerID
self.selectedPeerID = initialPeerID self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID) makeHeaderState(for: peerID)
@@ -154,7 +178,7 @@ final class PrivateConversationModel: ObservableObject {
} }
private func bind() { private func bind() {
conversationStore.$selectedPrivatePeerID conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.refreshSelectedConversation() self?.refreshSelectedConversation()
@@ -198,7 +222,7 @@ final class PrivateConversationModel: ObservableObject {
} }
private func refreshSelectedConversation() { private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID selectedPeerID = conversations.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID) makeHeaderState(for: peerID)
} }
+52 -18
View File
@@ -2,42 +2,76 @@ import BitFoundation
import Combine import Combine
import SwiftUI 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 @MainActor
final class PublicChatModel: ObservableObject { final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID @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<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(conversationStore: LegacyConversationStore) { init(conversations: ConversationStore) {
self.activeChannel = conversationStore.activeChannel let channel = conversations.activeChannel
self.conversationStore = conversationStore self.conversations = conversations
self.activeChannel = channel
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
observeActiveConversation()
bind() bind()
refreshMessages()
} }
private func bind() { private func bind() {
conversationStore.$activeChannel conversations.$activeChannel
.receive(on: DispatchQueue.main) .dropFirst()
.sink { [weak self] channel in .sink { [weak self] channel in
self?.activeChannel = channel guard let self else { return }
self?.refreshMessages() self.activeChannel = channel
self.retargetActiveConversation(to: channel)
} }
.store(in: &cancellables) .store(in: &cancellables)
conversationStore.$messagesByConversation // The store replaces a conversation's object when it is removed
.receive(on: DispatchQueue.main) // (panic clear); retarget to the fresh instance so the observation
.sink { [weak self] _ in // never goes stale.
self?.refreshMessages() 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) .store(in: &cancellables)
} }
private func refreshMessages() { private func retargetActiveConversation(to channel: ChannelID) {
let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel)) let conversation = conversations.conversation(for: ConversationID(channelID: channel))
guard messages != nextMessages else { return } guard conversation !== activeConversation else {
messages = nextMessages // 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()
}
} }
} }
-1
View File
@@ -31,7 +31,6 @@ protocol CommandContextProvider: AnyObject {
var activeChannel: ChannelID { get } var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get } var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get } var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get }
var idBridge: NostrIdentityBridge { get } var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup // MARK: - Peer Lookup
+2 -3
View File
@@ -14,9 +14,8 @@ import SwiftUI
/// Manages private chat session policy (selection, read receipts, /// Manages private chat session policy (selection, read receipts,
/// consolidation). Message storage lives in the single-writer /// consolidation). Message storage lives in the single-writer
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the /// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
/// `privateChats` / `unreadMessages` properties below are read-only compat /// `privateChats` / `unreadMessages` properties below are read-only views
/// views derived from it (migration step 2 the manager shrinks to /// derived from it.
/// read-receipt policy in step 5).
final class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var selectedPeer: PeerID? = nil @Published var selectedPeer: PeerID? = nil
-3
View File
@@ -51,9 +51,6 @@ enum TransportConfig {
static let nostrInboundEventLogInterval: Int = 100 static let nostrInboundEventLogInterval: Int = 100
// UI thresholds // 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 uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60 static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
@@ -13,7 +13,9 @@ import Foundation
protocol ChatLifecycleContext: AnyObject { protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state // MARK: Chat & receipt state
var messages: [BitchatMessage] { get } 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<PeerID> { get } var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get } var selectedPrivateChatPeer: PeerID? { get }
/// Appends a private message via the single-writer store intent. /// Appends a private message via the single-writer store intent.
@@ -73,7 +75,7 @@ protocol ChatLifecycleContext: AnyObject {
} }
extension ChatViewModel: ChatLifecycleContext { extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateChats`, `unreadPrivateMessages`, // `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`, // `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
@@ -187,7 +189,7 @@ final class ChatLifecycleCoordinator {
let recipientHex = context.nostrKeyMapping[peerID], let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel, case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { 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 { for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !context.sentReadReceipts.contains(message.id) else { continue } guard !context.sentReadReceipts.contains(message.id) else { continue }
@@ -253,14 +255,12 @@ final class ChatLifecycleCoordinator {
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = [] var combined: [BitchatMessage] = []
if let ephemeralMessages = context.privateChats[peerID] { combined.append(contentsOf: context.privateMessages(for: peerID))
combined.append(contentsOf: ephemeralMessages)
}
if let peer = context.unifiedPeer(for: peerID) { if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] { if noiseKeyHex != peerID {
combined.append(contentsOf: stableMessages) combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
} }
} }
@@ -25,7 +25,6 @@ protocol ChatMediaTransferContext: AnyObject {
func currentPublicSender() -> (name: String, peerID: PeerID) func currentPublicSender() -> (name: String, peerID: PeerID)
// MARK: Message state // MARK: Message state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// Appends a private message via the single-writer store intent. /// Appends a private message via the single-writer store intent.
@discardableResult @discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
@@ -52,7 +51,7 @@ protocol ChatMediaTransferContext: AnyObject {
extension ChatViewModel: ChatMediaTransferContext { extension ChatViewModel: ChatMediaTransferContext {
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`, // `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`, // `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
// `currentPublicSender()`, `privateChats`, // `currentPublicSender()`,
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`, // `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
// `addSystemMessage(_:)`, `notifyUIChanged()`, // `addSystemMessage(_:)`, `notifyUIChanged()`,
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`, // `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
@@ -18,6 +18,9 @@ import Foundation
protocol ChatPeerIdentityContext: AnyObject { protocol ChatPeerIdentityContext: AnyObject {
// MARK: Conversation state // MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get } 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<PeerID> { get } var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent). /// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID) func markPrivateChatRead(_ peerID: PeerID)
@@ -229,7 +232,7 @@ final class ChatPeerIdentityCoordinator {
@MainActor @MainActor
func openMostRelevantPrivateChat() { func openMostRelevantPrivateChat() {
let unreadSorted = context.unreadPrivateMessages 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 } .sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 { if let target = unreadSorted.first?.0 {
startPrivateChat(with: target) startPrivateChat(with: target)
@@ -563,7 +566,7 @@ private extension ChatPeerIdentityCoordinator {
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
if context.selectedPrivateChatPeer == oldPeerID { if context.selectedPrivateChatPeer == oldPeerID {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) 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) 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 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( let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
fallbackNickname: fallbackNickname, 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] ?? []
}
}
@@ -13,7 +13,9 @@ import Foundation
protocol ChatPeerListContext: AnyObject { protocol ChatPeerListContext: AnyObject {
// MARK: Connection & chat state // MARK: Connection & chat state
var isConnected: Bool { get set } 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<PeerID> { get } var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent). /// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID) func markPrivateChatRead(_ peerID: PeerID)
@@ -37,7 +39,7 @@ protocol ChatPeerListContext: AnyObject {
} }
extension ChatViewModel: ChatPeerListContext { extension ChatViewModel: ChatPeerListContext {
// `isConnected`, `privateChats`, `unreadPrivateMessages`, // `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`, // `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`, // `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and // `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
@@ -148,11 +150,11 @@ private extension ChatPeerListCoordinator {
var idsToRemove: [PeerID] = [] var idsToRemove: [PeerID] = []
for staleID in staleIDs { for staleID in staleIDs {
if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty { if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty {
continue continue
} }
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty { if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty {
continue continue
} }
@@ -15,6 +15,9 @@ import Foundation
protocol ChatPrivateConversationContext: AnyObject { protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state // MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get } 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<String> { get } var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get } var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get } var selectedPrivateChatPeer: PeerID? { get }
@@ -588,8 +591,8 @@ final class ChatPrivateConversationCoordinator {
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) { if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
let stableKeyHex = PeerID(hexData: peerNoiseKey) let stableKeyHex = PeerID(hexData: peerNoiseKey)
let nostrMessages = context.privateMessages(for: stableKeyHex)
if stableKeyHex != peerID, if stableKeyHex != peerID,
let nostrMessages = context.privateChats[stableKeyHex],
!nostrMessages.isEmpty { !nostrMessages.isEmpty {
// Store migration dedups by ID, keeps timestamp order, and // Store migration dedups by ID, keeps timestamp order, and
// removes the stable-key chat. // removes the stable-key chat.
@@ -767,7 +770,7 @@ final class ChatPrivateConversationCoordinator {
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
let currentFingerprint = context.getFingerprint(for: peerID) 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 // Chats migrated wholesale go through the store's
// `migrateConversation` intent; partially-migrated chats keep // `migrateConversation` intent; partially-migrated chats keep
// their non-recent tail, so the recent messages are copied in // their non-recent tail, so the recent messages are copied in
@@ -876,3 +879,11 @@ final class ChatPrivateConversationCoordinator {
return false 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] ?? []
}
}
@@ -51,9 +51,8 @@ protocol ChatPublicConversationContext: AnyObject {
func queueGeohashSystemMessage(_ content: String) func queueGeohashSystemMessage(_ content: String)
// MARK: Private chats (block cleanup & message removal) // MARK: Private chats (block cleanup & message removal)
var privateChats: [PeerID: [BitchatMessage]] { get }
/// Removes the peer's chat entirely, including unread state /// 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) func removePrivateChat(_ peerID: PeerID)
/// Removes a message by ID from every private chat containing it, /// Removes a message by ID from every private chat containing it,
/// dropping chats that become empty. Returns the removed message. /// dropping chats that become empty. Returns the removed message.
@@ -102,7 +101,7 @@ protocol ChatPublicConversationContext: AnyObject {
} }
extension ChatViewModel: ChatPublicConversationContext { extension ChatViewModel: ChatPublicConversationContext {
// `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`, // `unreadPrivateMessages`, `nostrKeyMapping`,
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`, // `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
// `myPeerID`, `isTeleported`, `notifyUIChanged()`, // `myPeerID`, `isTeleported`, `notifyUIChanged()`,
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
@@ -218,10 +217,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.removePublicMessages(fromGeohash: gh, where: predicate) context.removePublicMessages(fromGeohash: gh, where: predicate)
} }
let conversationPeerID = PeerID(nostr_: hex) // The store intent no-ops when no such chat exists.
if context.privateChats[conversationPeerID] != nil { context.removePrivateChat(PeerID(nostr_: hex))
context.removePrivateChat(conversationPeerID)
}
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
@@ -15,7 +15,9 @@ protocol ChatTransportEventContext: AnyObject {
var isConnected: Bool { get set } var isConnected: Bool { get set }
var nickname: String { get } var nickname: String { get }
var myPeerID: PeerID { 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<PeerID> { get } var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get set } var selectedPrivateChatPeer: PeerID? { get set }
/// Appends a private message via the single-writer store intent; /// Appends a private message via the single-writer store intent;
@@ -70,7 +72,7 @@ protocol ChatTransportEventContext: AnyObject {
} }
extension ChatViewModel: ChatTransportEventContext { extension ChatViewModel: ChatTransportEventContext {
// `isConnected`, `nickname`, `myPeerID`, `privateChats`, // `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`,
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`, // `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
// the inbound message handlers, `isPeerBlocked(_:)`, // the inbound message handlers, `isPeerBlocked(_:)`,
// `parseMentions(from:)`, `resolveNickname(for:)`, // `parseMentions(from:)`, `resolveNickname(for:)`,
@@ -233,12 +235,10 @@ final class ChatTransportEventCoordinator {
) )
} }
if let messages = context.privateChats[peerID] { let receiptIDs = context.privateMessages(for: peerID)
let receiptIDs = messages .filter { $0.senderPeerID == peerID }
.filter { $0.senderPeerID == peerID } .map(\.id)
.map(\.id) context.unmarkReadReceiptsSent(receiptIDs)
context.unmarkReadReceiptsSent(receiptIDs)
}
context.notifyUIChanged() context.notifyUIChanged()
} }
@@ -261,8 +261,9 @@ private extension ChatTransportEventCoordinator {
) { ) {
let hadUnread = context.unreadPrivateMessages.contains(shortPeerID) let hadUnread = context.unreadPrivateMessages.contains(shortPeerID)
if let messages = context.privateChats[shortPeerID] { let shortPeerMessages = context.privateMessages(for: shortPeerID)
for message in messages { if !shortPeerMessages.isEmpty {
for message in shortPeerMessages {
// Rewrite senderPeerID to the stable key so read receipts // Rewrite senderPeerID to the stable key so read receipts
// keep working; store append dedups by ID and keeps order. // keep working; store append dedups by ID and keeps order.
let migrated = BitchatMessage( let migrated = BitchatMessage(
+40 -73
View File
@@ -120,11 +120,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Published Properties // MARK: - Published Properties
/// Read-only derived view of the ACTIVE public channel's conversation in /// Read-only derived view of the ACTIVE public channel's conversation in
/// the single-writer `ConversationStore` (migration step 3 shim; views /// the single-writer `ConversationStore`. SwiftUI renders through
/// observe `Conversation` objects directly in step 5). Hot for rendering, /// `PublicChatModel` (which observes the `Conversation` object directly);
/// so the array is cached and invalidated from the store's `changes` /// this view serves the coordinators/commands that need "the visible
/// subject (filtered to the active conversation) and on channel switches. /// timeline" plus tests. Hot enough that the array is cached and
/// `objectWillChange` fires on every store change via the sink in `init`. /// 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 @MainActor
var messages: [BitchatMessage] { var messages: [BitchatMessage] {
if let cached = visibleMessagesCache { return cached } if let cached = visibleMessagesCache { return cached }
@@ -180,13 +182,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs } var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
@Published var allPeers: [BitchatPeer] = [] @Published var allPeers: [BitchatPeer] = []
/// Read-only compat view of all direct conversations in the new /// Read-only derived view of all direct conversations in the
/// `ConversationStore`, keyed by routing peer ID (migration step 2 shim; /// `ConversationStore`, keyed by routing peer ID. Serves the coordinator
/// views/feature models observe `Conversation` objects directly in /// reads that genuinely need the whole dictionary (migration scans,
/// step 5). All mutations go through the private-chat intent ops below. /// unread resolution); simple per-peer reads go through
/// Rebuilt per access O(#conversations) thanks to COW message arrays; /// `privateMessages(for:)` instead. All mutations go through the
/// measured equal to a change-invalidated cache on /// private-chat intent ops below. Rebuilt per access
/// `pipeline.privateIngest`, so the simpler form wins. /// O(#conversations) thanks to COW message arrays; measured equal to a
/// change-invalidated cache on `pipeline.privateIngest`, so the simpler
/// form wins.
@MainActor @MainActor
var privateChats: [PeerID: [BitchatMessage]] { var privateChats: [PeerID: [BitchatMessage]] {
conversations.directMessagesByRoutingPeerID() conversations.directMessagesByRoutingPeerID()
@@ -203,9 +207,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
synchronizeConversationSelectionStore() synchronizeConversationSelectionStore()
} }
} }
/// Read-only compat view of the store's unread direct conversations /// Read-only derived view of the store's unread direct conversations.
/// (migration step 2 shim). Mutate via `markPrivateChatUnread(_:)` / /// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`.
/// `markPrivateChatRead(_:)`.
@MainActor @MainActor
var unreadPrivateMessages: Set<PeerID> { var unreadPrivateMessages: Set<PeerID> {
conversations.unreadDirectRoutingPeerIDs() conversations.unreadDirectRoutingPeerIDs()
@@ -291,15 +294,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let meshService: Transport let meshService: Transport
let idBridge: NostrIdentityBridge let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol let identityManager: SecureIdentityStateManagerProtocol
let conversationStore: LegacyConversationStore /// Single source of truth for conversation message state and selection
/// Single source of truth for conversation message state
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed /// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
/// through, mirroring the legacy store's wiring. /// through.
let conversations: ConversationStore 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 peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore let locationPresenceStore: LocationPresenceStore
let locationManager: LocationChannelManager let locationManager: LocationChannelManager
@@ -310,12 +308,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
private let nicknameKey = "bitchat.nickname" private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection) // Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID { var activeChannel: ChannelID {
get { conversationStore.activeChannel } get { conversations.activeChannel }
set { set {
guard conversationStore.activeChannel != newValue else { return } guard conversations.activeChannel != newValue else { return }
conversationStore.setActiveChannel(newValue) conversations.setActiveChannel(newValue)
visibleMessagesCache = nil visibleMessagesCache = nil
synchronizeConversationSelectionStore()
objectWillChange.send() objectWillChange.send()
} }
} }
@@ -550,7 +547,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// The sole mutation paths for private (direct) message state. Each op // The sole mutation paths for private (direct) message state. Each op
// forwards to the single-writer `ConversationStore` // forwards to the single-writer `ConversationStore`
// (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` / // (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 /// 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 /// 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)) 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` /// `true` when any private chat contains a message with `messageID`
/// (O(1) per conversation via the store's ID indexes). /// (O(1) per conversation via the store's ID indexes).
@MainActor @MainActor
@@ -724,23 +729,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: KeychainManagerProtocol, keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge, idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol, identityManager: SecureIdentityStateManagerProtocol,
conversationStore: LegacyConversationStore? = nil,
conversations: ConversationStore? = nil, conversations: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
peerIdentityStore: PeerIdentityStore? = nil, peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil, locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared locationManager: LocationChannelManager = .shared
) { ) {
let conversationStore = conversationStore ?? LegacyConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
self.init( self.init(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
conversationStore: conversationStore,
conversations: conversations, conversations: conversations,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager locationManager: locationManager
@@ -755,16 +754,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
idBridge: NostrIdentityBridge, idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol, identityManager: SecureIdentityStateManagerProtocol,
transport: Transport, transport: Transport,
conversationStore: LegacyConversationStore? = nil,
conversations: ConversationStore? = nil, conversations: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
peerIdentityStore: PeerIdentityStore? = nil, peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil, locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared locationManager: LocationChannelManager = .shared
) { ) {
let conversationStore = conversationStore ?? LegacyConversationStore()
let conversations = conversations ?? ConversationStore() let conversations = conversations ?? ConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
let services = ChatViewModelServiceBundle( let services = ChatViewModelServiceBundle(
@@ -777,9 +772,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge self.idBridge = idBridge
self.identityManager = identityManager self.identityManager = identityManager
self.conversationStore = conversationStore
self.conversations = conversations self.conversations = conversations
self.identityResolver = identityResolver
self.peerIdentityStore = peerIdentityStore self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore self.locationPresenceStore = locationPresenceStore
self.locationManager = locationManager self.locationManager = locationManager
@@ -793,21 +786,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.publicMessagePipeline = services.publicMessagePipeline self.publicMessagePipeline = services.publicMessagePipeline
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts() 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 // Republish on every store change so SwiftUI observers of the
// view model refresh. This replaces the UI-update role of the old // view model refresh. This replaces the UI-update role of the old
// `PrivateChatManager.@Published` dictionaries and the old // `PrivateChatManager.@Published` dictionaries and the old
// `@Published var messages` (their debounced Legacy synchronization // `@Published var messages`. Changes touching the ACTIVE public
// sinks are gone; the bridge above feeds Legacy instead). Changes // conversation also invalidate the derived `messages` cache before
// touching the ACTIVE public conversation also invalidate the // observers re-read it.
// derived `messages` cache before observers re-read it.
conversations.changes conversations.changes
.sink { [weak self] change in .sink { [weak self] change in
guard let self else { return } guard let self else { return }
@@ -819,7 +803,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
.store(in: &cancellables) .store(in: &cancellables)
ChatViewModelBootstrapper(viewModel: self).configure() ChatViewModelBootstrapper(viewModel: self).configure()
initializeConversationStore() synchronizeConversationSelectionStore()
} }
// MARK: - Deinitialization // MARK: - Deinitialization
@@ -1156,7 +1140,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
bleService.resetIdentityForPanic(currentNickname: nickname) bleService.resetIdentityForPanic(currentNickname: nickname)
} }
initializeConversationStore() synchronizeConversationSelectionStore()
// No need to force UserDefaults synchronization // No need to force UserDefaults synchronization
@@ -1278,28 +1262,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Handling // MARK: - Message Handling
@MainActor /// Pushes the private-chat manager's selection into the store, which
func initializeConversationStore() { /// derives `selectedConversationID` from it and the active channel.
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()
}
@MainActor @MainActor
func synchronizeConversationSelectionStore() { func synchronizeConversationSelectionStore() {
conversationStore.setSelectedPeerID( conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer)
privateChatManager.selectedPeer,
activeChannel: activeChannel,
identityResolver: identityResolver
)
} }
/// Invalidates the derived `messages` cache and notifies observers. /// Invalidates the derived `messages` cache and notifies observers.
@@ -90,10 +90,9 @@ private extension ChatViewModelBootstrapper {
} }
.store(in: &viewModel.cancellables) .store(in: &viewModel.cancellables)
// Private message state now flows: store intent // Private message state flows through the single-writer
// `ConversationStore.changes` `LegacyConversationStoreBridge` (and // `ConversationStore` intents and its `changes` subject; only the
// the ChatViewModel shim-cache sink), so the old `$privateChats` / // selection still originates in `PrivateChatManager`.
// `$unreadMessages` debounced synchronization sinks are gone.
viewModel.privateChatManager.$selectedPeer viewModel.privateChatManager.$selectedPeer
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in .sink { [weak viewModel] _ in
@@ -159,7 +158,6 @@ private extension ChatViewModelBootstrapper {
guard let viewModel else { return } guard let viewModel else { return }
viewModel.allPeers = peers viewModel.allPeers = peers
viewModel.identityResolver.register(peers: peers)
var uniquePeers: [PeerID: BitchatPeer] = [:] var uniquePeers: [PeerID: BitchatPeer] = [:]
for peer in peers { for peer in peers {
@@ -178,9 +176,6 @@ private extension ChatViewModelBootstrapper {
viewModel.updatePrivateChatPeerIfNeeded() viewModel.updatePrivateChatPeerIfNeeded()
} }
// Peer registrations can change a conversation's
// canonical handle in the legacy store; re-key it.
viewModel.resynchronizeLegacyPrivateConversations()
viewModel.synchronizeConversationSelectionStore() viewModel.synchronizeConversationSelectionStore()
} }
} }
@@ -81,7 +81,7 @@ struct CommandSuggestionsView: View {
) )
let privateConversationModel = PrivateConversationModel( let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: viewModel.conversationStore conversations: viewModel.conversations
) )
let locationChannelsModel = LocationChannelsModel() let locationChannelsModel = LocationChannelsModel()
@@ -76,12 +76,12 @@ struct TextMessageView: View {
) )
let privateConversationModel = PrivateConversationModel( let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: viewModel.conversationStore conversations: viewModel.conversations
) )
let conversationUIModel = ConversationUIModel( let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel, chatViewModel: viewModel,
privateConversationModel: privateConversationModel, privateConversationModel: privateConversationModel,
conversationStore: viewModel.conversationStore conversations: viewModel.conversations
) )
Group { Group {
+156 -213
View File
@@ -1,4 +1,5 @@
import BitFoundation import BitFoundation
import Combine
import Foundation import Foundation
import Testing import Testing
@testable import bitchat @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 @MainActor
private func waitUntil( private func waitUntil(
timeoutNanoseconds: UInt64 = 3_000_000_000, timeoutNanoseconds: UInt64 = 3_000_000_000,
@@ -127,251 +149,119 @@ struct AppArchitectureTests {
@Test("PeerHandle equality and hashing use the canonical identity only") @Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() { func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle( let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
id: "noise:abc123", let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b"))
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"
)
#expect(first == second) #expect(first == second)
#expect(Set([first, second]).count == 1) #expect(Set([first, second]).count == 1)
} }
@Test("LegacyConversationStore normalizes timeline ordering and duplicates") @Test("ConversationStore orders timelines and replaces duplicates by message ID")
@MainActor @MainActor
func conversationStoreNormalizesMessages() { func conversationStoreOrdersAndDedupsMessages() {
let store = LegacyConversationStore() let store = ConversationStore()
let older = BitchatMessage( let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first")
id: "m1", let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second")
sender: "alice", let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated")
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")
)
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.map(\.id) == ["m1", "m2"])
#expect(messages.last?.content == "second-updated") #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 @MainActor
func conversationStoreTracksUnreadDirectConversations() { func conversationStoreTracksUnreadDirectConversations() {
let store = LegacyConversationStore() let store = ConversationStore()
let resolver = IdentityResolver()
let peerID = PeerID(str: "peer-1") let peerID = PeerID(str: "peer-1")
let message = BitchatMessage( let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID)
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "bob",
senderPeerID: peerID
)
store.synchronizePrivateChats( store.append(message, to: .directPeer(peerID))
[peerID: [message]], store.markUnread(.directPeer(peerID))
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
let conversationID = ConversationID.direct( #expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"])
resolver.canonicalHandle(for: peerID, displayName: "alice") #expect(store.unreadDirectRoutingPeerIDs() == Set([peerID]))
) #expect(store.conversation(for: .directPeer(peerID)).isUnread)
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"]) store.markRead(.directPeer(peerID))
#expect(store.unreadConversations.contains(conversationID)) #expect(store.unreadDirectRoutingPeerIDs().isEmpty)
#expect(!store.conversation(for: .directPeer(peerID)).isUnread)
store.markRead(conversationID)
#expect(!store.unreadConversations.contains(conversationID))
} }
@Test("LegacyConversationStore tracks the selected app conversation context") @Test("ConversationStore derives the selected conversation from channel and private peer")
@MainActor @MainActor
func conversationStoreTracksSelectedConversationContext() { func conversationStoreTracksSelectedConversationContext() {
let store = LegacyConversationStore() let store = ConversationStore()
let resolver = IdentityResolver() let peerID = PeerID(str: "0011223344556677")
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) 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.setActiveChannel(geohashChannel)
store.synchronizeSelection( store.setSelectedPrivatePeer(peerID)
activeChannel: geohashChannel,
selectedPeerID: shortPeerID,
identityResolver: resolver
)
let expectedConversationID = ConversationID.direct(
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
)
#expect(store.activeChannel == geohashChannel) #expect(store.activeChannel == geohashChannel)
#expect(store.selectedPrivatePeerID == shortPeerID) #expect(store.selectedPrivatePeerID == peerID)
#expect(store.selectedConversationID == expectedConversationID) // The open private chat wins the derived selection.
#expect(store.selectedConversationID == ConversationID.directPeer(peerID))
store.synchronizeSelection( store.setSelectedPrivatePeer(nil)
activeChannel: ChannelID.mesh, // Selection falls back to the active public channel.
selectedPeerID: nil, #expect(store.selectedConversationID == ConversationID(channelID: geohashChannel))
identityResolver: resolver
)
store.setActiveChannel(.mesh)
#expect(store.activeChannel == ChannelID.mesh) #expect(store.activeChannel == ChannelID.mesh)
#expect(store.selectedPrivatePeerID == nil) #expect(store.selectedPrivatePeerID == nil)
#expect(store.selectedConversationID == ConversationID.mesh) #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 @MainActor
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() { func conversationStoreMigratesDirectConversationsBetweenPeerIDs() {
let store = LegacyConversationStore() let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init)) let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677") let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey) 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( store.append(
peer: BitchatPeer( makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID),
peerID: shortPeerID, to: .directPeer(shortPeerID)
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[shortPeerID: [firstMessage]],
unreadPeerIDs: Set([shortPeerID]),
identityResolver: resolver
) )
store.markUnread(.directPeer(shortPeerID))
store.setSelectedPrivatePeer(shortPeerID)
resolver.register( store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID))
peer: BitchatPeer(
peerID: fullPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[fullPeerID: [secondMessage]],
unreadPeerIDs: Set([fullPeerID]),
identityResolver: resolver
)
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID])) // Raw keying: the old peer's conversation is gone, the new peer's
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"]) // conversation holds the timeline, unread and selection carried over.
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID])) #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 @MainActor
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async { func privateInboxModelReadsDirectMessageStateFromConversationStore() {
let store = LegacyConversationStore() let store = ConversationStore()
let resolver = IdentityResolver() let inboxModel = PrivateInboxModel(conversations: store)
let inboxModel = PrivateInboxModel(conversationStore: store)
let messagePeerID = PeerID(str: "peer-1") let messagePeerID = PeerID(str: "peer-1")
let unreadOnlyPeerID = PeerID(str: "peer-2") let unreadOnlyPeerID = PeerID(str: "peer-2")
let selectedOnlyPeerID = PeerID(str: "peer-3") 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( store.append(
[messagePeerID: [message]], makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID),
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]), to: .directPeer(messagePeerID)
identityResolver: resolver
)
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: selectedOnlyPeerID,
identityResolver: resolver
) )
store.markUnread(.directPeer(messagePeerID))
store.markUnread(.directPeer(unreadOnlyPeerID))
store.setSelectedPrivatePeer(selectedOnlyPeerID)
await waitUntil { // Reads are synchronous against the single-writer store.
inboxModel.selectedPeerID == selectedOnlyPeerID &&
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
}
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID) #expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID])) #expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"]) #expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
@@ -379,13 +269,74 @@ struct AppArchitectureTests {
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty) #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") @Test("AppChromeModel mirrors nickname and unread state through focused models")
@MainActor @MainActor
func appChromeModelMirrorsNicknameAndUnreadState() async { func appChromeModelMirrorsNicknameAndUnreadState() async {
let viewModel = makeArchitectureViewModel() let viewModel = makeArchitectureViewModel()
let conversationStore = LegacyConversationStore() let conversations = ConversationStore()
let resolver = IdentityResolver() let privateInboxModel = PrivateInboxModel(conversations: conversations)
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
chromeModel.setNickname("builder") chromeModel.setNickname("builder")
@@ -398,11 +349,7 @@ struct AppArchitectureTests {
#expect(!chromeModel.hasUnreadPrivateMessages) #expect(!chromeModel.hasUnreadPrivateMessages)
let peerID = PeerID(str: "peer-1") let peerID = PeerID(str: "peer-1")
conversationStore.synchronizePrivateChats( conversations.markUnread(.directPeer(peerID))
[:],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
await waitUntil { await waitUntil {
chromeModel.hasUnreadPrivateMessages chromeModel.hasUnreadPrivateMessages
} }
@@ -414,8 +361,7 @@ struct AppArchitectureTests {
@MainActor @MainActor
func appChromeModelOwnsPresentationState() { func appChromeModelOwnsPresentationState() {
let viewModel = makeArchitectureViewModel() let viewModel = makeArchitectureViewModel()
let conversationStore = LegacyConversationStore() let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
let peerID = PeerID(str: "peer-2") let peerID = PeerID(str: "peer-2")
@@ -441,11 +387,10 @@ struct AppArchitectureTests {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return return
} }
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let conversationModel = PrivateConversationModel( let conversationModel = PrivateConversationModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: conversationStore, conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
@@ -493,18 +438,17 @@ struct AppArchitectureTests {
return return
} }
let conversationStore = viewModel.conversationStore
locationManager.select(.mesh) locationManager.select(.mesh)
let locationChannelsModel = LocationChannelsModel(manager: locationManager) let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateConversationModel = PrivateConversationModel( let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: conversationStore, conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
let uiModel = ConversationUIModel( let uiModel = ConversationUIModel(
chatViewModel: viewModel, chatViewModel: viewModel,
privateConversationModel: privateConversationModel, privateConversationModel: privateConversationModel,
conversationStore: conversationStore conversations: viewModel.conversations
) )
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
defer { defer {
@@ -558,11 +502,10 @@ struct AppArchitectureTests {
let peerID = PeerID(str: "0011223344556677") let peerID = PeerID(str: "0011223344556677")
let fingerprint = "verified-fingerprint" let fingerprint = "verified-fingerprint"
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let privateConversationModel = PrivateConversationModel( let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: conversationStore, conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
let verificationModel = VerificationModel( let verificationModel = VerificationModel(
@@ -664,7 +607,7 @@ struct AppArchitectureTests {
let peerListModel = PeerListModel( let peerListModel = PeerListModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: viewModel.conversationStore, conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
@@ -29,6 +29,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
// Chat & receipt state // Chat & receipt state
var messages: [BitchatMessage] = [] var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:] var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = [] var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID? var selectedPrivateChatPeer: PeerID?
var sentReadReceipts: Set<String> = [] var sentReadReceipts: Set<String> = []
@@ -27,6 +27,10 @@ private final class MockChatPeerListContext: ChatPeerListContext {
// Connection & chat state // Connection & chat state
var isConnected = false var isConnected = false
var privateChats: [PeerID: [BitchatMessage]] = [:] var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = [] var unreadPrivateMessages: Set<PeerID> = []
var hasTrackedPrivateChatSelection = false var hasTrackedPrivateChatSelection = false
private(set) var updatePrivateChatPeerIfNeededCount = 0 private(set) var updatePrivateChatPeerIfNeededCount = 0
@@ -28,6 +28,10 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
var nickname = "me" var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677") var myPeerID = PeerID(str: "0011223344556677")
var privateChats: [PeerID: [BitchatMessage]] = [:] var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = [] var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID? var selectedPrivateChatPeer: PeerID?
private(set) var unmarkedReadReceiptBatches: [[String]] = [] private(set) var unmarkedReadReceiptBatches: [[String]] = []
+1 -4
View File
@@ -553,10 +553,7 @@ struct ChatViewModelNoisePayloadTests {
}, timeout: TestConstants.defaultTimeout) }, timeout: TestConstants.defaultTimeout)
let conversationStoreUpdated = await TestHelpers.waitUntil({ let conversationStoreUpdated = await TestHelpers.waitUntil({
let messages = viewModel.conversationStore.directMessages( let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
for: peerID,
identityResolver: viewModel.identityResolver
)
guard let status = messages.first?.deliveryStatus else { return false } guard let status = messages.first?.deliveryStatus else { return false }
if case .read = status { if case .read = status {
return true return true
+1 -4
View File
@@ -41,10 +41,7 @@ private func makeMessage(
private func makeDirectConversationID(_ suffix: String) -> ConversationID { private func makeDirectConversationID(_ suffix: String) -> ConversationID {
.direct(PeerHandle( .direct(PeerHandle(
id: "noise:\(suffix)", id: "noise:\(suffix)",
routingPeerID: PeerID(str: "peer-\(suffix)"), routingPeerID: PeerID(str: "peer-\(suffix)")
displayName: "peer \(suffix)",
noisePublicKeyHex: suffix,
nostrPublicKey: nil
)) ))
} }
@@ -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 <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
@MainActor
private func makeBridgedFixture() -> (
viewModel: ChatViewModel,
store: ConversationStore,
legacy: LegacyConversationStore,
transport: MockTransport
) {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let legacy = LegacyConversationStore()
let store = ConversationStore()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
conversationStore: legacy,
conversations: store
)
return (viewModel, store, legacy, transport)
}
@MainActor
private func makeInboundPrivateMessage(
id: String,
from peerID: PeerID,
sender: String = "alice",
content: String = "hello",
timestamp: Date = Date()
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: peerID,
deliveryStatus: .delivered(to: "me", at: timestamp)
)
}
@Suite("LegacyConversationStoreBridge")
struct LegacyConversationStoreBridgeTests {
@Test("inbound private message lands in the store and is mirrored into Legacy")
@MainActor
func inboundPrivateMessageLandsInStoreAndLegacy() async {
let (viewModel, store, legacy, _) = makeBridgedFixture()
// Stable Noise-key peer ID, like the perf pipeline fixture.
let peerID = PeerID(str: String(repeating: "ab", count: 32))
let message = makeInboundPrivateMessage(id: "bridge-pm-1", from: peerID)
viewModel.handlePrivateMessage(message)
// New store is the synchronous source of truth.
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["bridge-pm-1"])
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["bridge-pm-1"])
// Unread flips mirror into Legacy synchronously.
#expect(viewModel.unreadPrivateMessages.contains(peerID))
#expect(legacy.unreadDirectPeerIDs().contains(peerID))
// Message arrays mirror via the bridge's coalesced flush
// (one Legacy replace per burst, on the next main-actor turn).
let mirrored = await TestHelpers.waitUntil(
{ legacy.directMessagesByPeerID()[peerID]?.map(\.id) == ["bridge-pm-1"] },
timeout: 1.0
)
#expect(mirrored)
}
@Test("store dedups a private message delivered twice")
@MainActor
func storeDedupsDuplicateInboundMessage() async {
let (viewModel, store, legacy, _) = makeBridgedFixture()
let peerID = PeerID(str: String(repeating: "cd", count: 32))
let message = makeInboundPrivateMessage(id: "bridge-dup-1", from: peerID)
viewModel.handlePrivateMessage(message)
viewModel.handlePrivateMessage(message)
#expect(store.conversation(for: .directPeer(peerID)).messages.count == 1)
#expect(viewModel.privateChats[peerID]?.count == 1)
// The intent itself reports the duplicate.
#expect(!viewModel.appendPrivateMessage(message, to: peerID))
let mirrored = await TestHelpers.waitUntil(
{ legacy.directMessagesByPeerID()[peerID]?.count == 1 },
timeout: 1.0
)
#expect(mirrored)
}
@Test("peer-ID migration through store intents keeps Legacy consistent")
@MainActor
func migrationThroughStoreIntentsKeepsLegacyConsistent() {
let (viewModel, store, legacy, _) = makeBridgedFixture()
let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa")
let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb")
viewModel.seedPrivateChat(
[
makeInboundPrivateMessage(id: "mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-60)),
makeInboundPrivateMessage(id: "mig-2", from: oldPeerID, timestamp: Date().addingTimeInterval(-30)),
],
for: oldPeerID
)
viewModel.markPrivateChatUnread(oldPeerID)
viewModel.migratePrivateChat(from: oldPeerID, to: newPeerID)
// Store: messages moved in order, old chat gone, unread carried.
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["mig-1", "mig-2"])
#expect(viewModel.unreadPrivateMessages == [newPeerID])
// Legacy: structural changes resynchronize immediately (stale
// messages removed, destination present, unread re-keyed). The old
// peer may linger as a registered-but-empty handle Legacy has
// always kept handle registrations from markRead/selection around.
let legacyChats = legacy.directMessagesByPeerID()
#expect(legacyChats[oldPeerID] ?? [] == [])
#expect(legacyChats[newPeerID]?.map(\.id) == ["mig-1", "mig-2"])
#expect(legacy.unreadDirectPeerIDs() == [newPeerID])
}
@Test("coordinator chat migration uses the store migrate intent end to end")
@MainActor
func coordinatorMigrationFlowsThroughStore() {
let (viewModel, store, legacy, _) = makeBridgedFixture()
let oldPeerID = PeerID(str: "1111111111111111")
let newPeerID = PeerID(str: "2222222222222222")
// Recent messages from "alice" under the old peer ID; no fingerprints
// on either side nickname-match migration path.
viewModel.seedPrivateChat(
[makeInboundPrivateMessage(id: "coord-mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-5))],
for: oldPeerID
)
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice")
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["coord-mig-1"])
// Migration resynchronizes Legacy immediately (the old peer may
// linger as a registered-but-empty handle, see above).
#expect(legacy.directMessagesByPeerID()[oldPeerID] ?? [] == [])
#expect(legacy.directMessagesByPeerID()[newPeerID]?.map(\.id) == ["coord-mig-1"])
}
// 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)
}
}
@@ -315,14 +315,13 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 6a. End-to-end private ingest pipeline (current architecture) // MARK: - 6a. End-to-end private ingest pipeline (current architecture)
/// Baseline for the full private-message ingest cycle through a real /// Baseline for the full private-message ingest cycle through a real
/// `ChatViewModel`: `handlePrivateMessage` `privateChats` passthrough /// `ChatViewModel`: `handlePrivateMessage` `ConversationStore.append`
/// `PrivateChatManager`'s `@Published` dict bootstrapper Combine sink /// intent (ordered insert + ID-index dedup) per-conversation publish +
/// `Task.yield`-debounced `LegacyConversationStore.synchronizePrivateChats` /// `changes` emission `PrivateInboxModel` (direct store reads).
/// (full-dict replace) `PrivateInboxModel.refreshMessages` (full /// Measures wall time from first ingest until the store AND the feature
/// rebuild). Measures wall time from first ingest until the store AND the /// model both reflect every message. The peer is not mesh-active and no
/// feature model both reflect every message. The peer is not mesh-active /// chat is selected, so notification/read-receipt side paths stay cold
/// and no chat is selected, so notification/read-receipt side paths stay /// (notifications are no-ops under test anyway).
/// cold (notifications are no-ops under test anyway).
func testPipelinePrivateIngest() { func testPipelinePrivateIngest() {
let messageCount = 200 let messageCount = 200
// 64-hex (stable Noise key) peer ID: skips the short-ID consolidation // 64-hex (stable Noise key) peer ID: skips the short-ID consolidation
@@ -354,14 +353,14 @@ final class PerformanceBaselineTests: XCTestCase {
for message in messages { for message in messages {
fixture.viewModel.handlePrivateMessage(message) fixture.viewModel.handlePrivateMessage(message)
} }
// Drain the main queue until the debounced LegacyConversationStore sync // Reads are synchronous against the single-writer store; the
// ran and the PrivateInboxModel mirror caught up. // spin covers any coordinator main-actor hops.
let consistent = spinMainRunLoop(timeout: 10) { let consistent = spinMainRunLoop(timeout: 10) {
fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount fixture.conversations.conversationsByID[.directPeer(peerID)]?.messages.count == messageCount
&& fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount && fixture.privateInbox.messages(for: peerID).count == messageCount
} }
samples.append(Date().timeIntervalSince(start)) samples.append(Date().timeIntervalSince(start))
XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged") XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged")
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount) XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
} }
@@ -374,8 +373,8 @@ final class PerformanceBaselineTests: XCTestCase {
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry, /// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
/// main-actor Task hop per message) `handlePublicMessage` (rate limit, /// main-actor Task hop per message) `handlePublicMessage` (rate limit,
/// pipeline enqueue) `PublicMessagePipeline` timer-batched flush into /// pipeline enqueue) `PublicMessagePipeline` timer-batched flush into
/// the `ConversationStore` (derived `ChatViewModel.messages` view) /// the `ConversationStore` (derived `ChatViewModel.messages` view;
/// coalesced `LegacyConversationStoreBridge` mirror `PublicChatModel`. /// `PublicChatModel` observes the active `Conversation` directly).
/// Measures until `messages` and the feature model reflect every message, /// Measures until `messages` and the feature model reflect every message,
/// so the pipeline's flush latency is part of the cycle. Senders are /// 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. /// 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 // Drain the per-message main-actor hops and whichever surfacing
// path wins (the pipeline's batched timer flush or the startup // path wins (the pipeline's batched timer flush or the startup
// channel apply's `refreshVisibleMessages`, which pulls the whole // channel apply's `refreshVisibleMessages`); `PublicChatModel`
// timeline into `messages`), plus the PublicChatModel mirror. // reads the same conversation synchronously.
let consistent = spinMainRunLoop(timeout: 10) { let consistent = spinMainRunLoop(timeout: 10) {
fixture.viewModel.messages.count >= messageCount fixture.viewModel.messages.count >= messageCount
&& fixture.publicChat.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 /// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style
/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same /// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same
/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full /// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover
/// current store-synchronization chain. Mirrors the construction used by /// the full ingest-to-feature-model chain. Mirrors the construction used by
/// `ChatViewModelExtensionsTests`. /// `ChatViewModelExtensionsTests`.
@MainActor @MainActor
private final class PerfPipelineFixture { private final class PerfPipelineFixture {
let viewModel: ChatViewModel let viewModel: ChatViewModel
let transport: MockTransport let transport: MockTransport
let conversationStore: LegacyConversationStore let conversations: ConversationStore
let privateInbox: PrivateInboxModel let privateInbox: PrivateInboxModel
let publicChat: PublicChatModel let publicChat: PublicChatModel
@@ -736,19 +735,19 @@ private final class PerfPipelineFixture {
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let identityManager = MockIdentityManager(keychain) let identityManager = MockIdentityManager(keychain)
let transport = MockTransport() let transport = MockTransport()
let conversationStore = LegacyConversationStore() let conversations = ConversationStore()
self.transport = transport self.transport = transport
self.conversationStore = conversationStore self.conversations = conversations
self.viewModel = ChatViewModel( self.viewModel = ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
transport: transport, transport: transport,
conversationStore: conversationStore conversations: conversations
) )
self.privateInbox = PrivateInboxModel(conversationStore: conversationStore) self.privateInbox = PrivateInboxModel(conversations: conversations)
self.publicChat = PublicChatModel(conversationStore: conversationStore) self.publicChat = PublicChatModel(conversations: conversations)
} }
} }
+6 -6
View File
@@ -52,17 +52,17 @@ private func makeSmokeLocationManager() -> LocationChannelManager {
@MainActor @MainActor
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels { private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
let locationManager = makeSmokeLocationManager() let locationManager = makeSmokeLocationManager()
let conversationStore = viewModel.conversationStore let conversations = viewModel.conversations
let publicChatModel = PublicChatModel(conversationStore: conversationStore) let publicChatModel = PublicChatModel(conversations: conversations)
let locationChannelsModel = LocationChannelsModel(manager: locationManager) let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) let privateInboxModel = PrivateInboxModel(conversations: conversations)
let appChromeModel = AppChromeModel( let appChromeModel = AppChromeModel(
chatViewModel: viewModel, chatViewModel: viewModel,
privateInboxModel: privateInboxModel privateInboxModel: privateInboxModel
) )
let privateConversationModel = PrivateConversationModel( let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: conversationStore, conversations: conversations,
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
let verificationModel = VerificationModel( let verificationModel = VerificationModel(
@@ -72,11 +72,11 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
let conversationUIModel = ConversationUIModel( let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel, chatViewModel: viewModel,
privateConversationModel: privateConversationModel, privateConversationModel: privateConversationModel,
conversationStore: conversationStore conversations: conversations
) )
let peerListModel = PeerListModel( let peerListModel = PeerListModel(
chatViewModel: viewModel, chatViewModel: viewModel,
conversationStore: conversationStore, conversations: conversations,
locationChannelsModel: locationChannelsModel locationChannelsModel: locationChannelsModel
) )
+36 -8
View File
@@ -1,11 +1,39 @@
# Conversation Store: Single Source of Truth # Conversation Store: Single Source of Truth
**Status:** Steps 14 implemented (additive store, private cutover, public **Status: migration complete (steps 15).** `ConversationStore` is the sole
cutover, delivery via store; `PublicTimelineStore` and holder of message state; the feature models (`PublicChatModel`,
`ChatDeliveryCoordinator.messageLocationIndex` deleted). Baselines recorded in `PrivateInboxModel`, `PrivateConversationModel`, `PeerListModel`,
`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`, `ConversationUIModel`) observe it directly — `PublicChatModel` observes the
`pipeline.publicIngest`, `store.append`, `delivery.incrementalUpdate`, active `Conversation` object, so background appends never invalidate it.
`delivery.storeUpdate`). `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 is one copy, "await the sync" disappears: after `append` returns, every observer of
that `Conversation` sees the message. that `Conversation` sees the message.
## 3. Deleted at end state ## 3. Deleted at end state (done)
- `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into - `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into
`Conversation` cap/dedup policy. `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). `PublicChatModel.messages` (they observe `Conversation` objects directly).
- `ChatViewModel.messages` / `ChatViewModel.privateChats` as stored/owning properties. - `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` Each step lands green against the full suite plus the `PerformanceBaselineTests`
numbers (no pipeline throughput regression at any step). numbers (no pipeline throughput regression at any step).