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 {
let id: String
let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
}
extension PeerHandle: Equatable {
@@ -100,310 +100,3 @@ enum ConversationID: Hashable, Sendable {
}
}
}
@MainActor
final class IdentityResolver {
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
private var handlesByNoiseKey: [String: PeerHandle] = [:]
private var handlesByNostrKey: [String: PeerHandle] = [:]
func register(peers: [BitchatPeer]) {
for peer in peers {
_ = register(peer: peer)
}
}
@discardableResult
func register(peer: BitchatPeer) -> PeerHandle {
let handle = buildHandle(
routingPeerID: peer.peerID,
displayName: peer.displayName,
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
)
cache(handle)
return handle
}
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
if let handle = handlesByRoutingPeerID[peerID] {
return handle
}
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
return handle
}
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
return handle
}
let handle = buildHandle(
routingPeerID: peerID,
displayName: displayName,
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
)
cache(handle)
return handle
}
private func buildHandle(
routingPeerID: PeerID,
displayName: String?,
noisePublicKeyHex: String?,
nostrPublicKey: String?
) -> PeerHandle {
let canonicalID: String
if let noisePublicKeyHex {
canonicalID = "noise:\(noisePublicKeyHex)"
} else if let nostrPublicKey {
canonicalID = "nostr:\(nostrPublicKey)"
} else {
canonicalID = "mesh:\(routingPeerID.id)"
}
let normalizedDisplayName: String?
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
normalizedDisplayName = displayName
} else {
normalizedDisplayName = nil
}
return PeerHandle(
id: canonicalID,
routingPeerID: routingPeerID,
displayName: normalizedDisplayName,
noisePublicKeyHex: noisePublicKeyHex,
nostrPublicKey: nostrPublicKey
)
}
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
guard let nostrPublicKey else { return nil }
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private func cache(_ handle: PeerHandle) {
handlesByRoutingPeerID[handle.routingPeerID] = handle
if let noisePublicKeyHex = handle.noisePublicKeyHex {
handlesByNoiseKey[noisePublicKeyHex] = handle
}
if let nostrPublicKey = handle.nostrPublicKey {
handlesByNostrKey[nostrPublicKey] = handle
}
}
}
@MainActor
final class LegacyConversationStore: ObservableObject {
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
@Published private(set) var selectedConversationID: ConversationID = .mesh
@Published private(set) var unreadConversations: Set<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 {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: LegacyConversationStore
/// Single source of truth for conversation message state
/// (docs/CONVERSATION-STORE-DESIGN.md). The legacy store above keeps
/// feeding the feature models until step 5, mirrored from this one by
/// `LegacyConversationStoreBridge`.
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
@@ -47,13 +45,10 @@ final class AppRuntime: ObservableObject {
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = LegacyConversationStore()
let conversations = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
@@ -61,19 +56,17 @@ final class AppRuntime: ObservableObject {
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
conversations: conversations,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
self.publicChatModel = PublicChatModel(conversations: conversations)
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
@@ -85,11 +78,11 @@ final class AppRuntime: ObservableObject {
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore
conversations: conversations
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
@@ -226,7 +219,7 @@ final class AppRuntime: ObservableObject {
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID {
if conversations.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
+61 -25
View File
@@ -7,9 +7,9 @@
// `ConversationID`; all mutations flow through the store's intent API and
// every mutation emits a `ConversationChange` after state is consistent.
//
// During migration the previous replace-based store lives on as
// `LegacyConversationStore` (AppArchitecture.swift) and is deleted in the
// final migration step.
// The store also owns conversation selection: the active public channel and
// the selected private peer (the two UI selection axes) plus the derived
// `selectedConversationID`.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
@@ -257,6 +257,16 @@ final class ConversationStore: ObservableObject {
@Published private(set) var selectedConversationID: 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] = [:]
/// Store-level message-ID conversation-membership map for ID-only
@@ -391,6 +401,32 @@ final class ConversationStore: ObservableObject {
selectedConversationID = id
}
/// Switches the active public channel. While no private chat is open
/// the selection follows the channel.
func setActiveChannel(_ channelID: ChannelID) {
if activeChannel != channelID {
activeChannel = channelID
}
refreshDerivedSelection()
}
/// Opens a private chat (`nil` closes it, returning the selection to the
/// active public channel's conversation).
func setSelectedPrivatePeer(_ peerID: PeerID?) {
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
}
refreshDerivedSelection()
}
private func refreshDerivedSelection() {
if let peerID = selectedPrivatePeerID {
select(.directPeer(peerID))
} else {
select(ConversationID(channelID: activeChannel))
}
}
/// Moves all messages from `source` into `destination` (the
/// ephemeralstable peer-ID handoff): dedups by message ID, preserves
/// timestamp order, carries unread state over, and hands off the
@@ -424,6 +460,13 @@ final class ConversationStore: ObservableObject {
}
if wasSelected {
selectedConversationID = destination
// Keep the private-peer selection axis consistent with the
// handed-off selection.
if let peerID = selectedPrivatePeerID,
source == .directPeer(peerID),
case .direct(let destinationHandle) = destination {
selectedPrivatePeerID = destinationHandle.routingPeerID
}
}
changes.send(.migrated(from: source, to: destination))
@@ -532,32 +575,27 @@ final class ConversationStore: ObservableObject {
}
}
// MARK: - Migration step 2 compatibility (raw per-peer keying + derived views)
// MARK: - Direct-conversation keying + derived views
extension ConversationID {
/// Direct-conversation ID keyed by the *raw* routing peer ID.
///
/// Migration step 2 keeps one conversation per `PeerID` exactly the
/// buckets the legacy `privateChats` dictionary had so the
/// ephemeral/stable mirroring and consolidation coordinators keep their
/// current semantics. Step 5 canonicalizes direct conversations through
/// `IdentityResolver` and this helper goes away.
/// Direct conversations are deliberately keyed per `PeerID`, not per
/// resolved identity: the private-chat coordinators mirror messages into
/// both the ephemeral and stable peer's conversations
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
/// explicitly, so a raw lookup by whichever peer ID is selected always
/// finds the right timeline without an identity-resolution layer.
static func directPeer(_ peerID: PeerID) -> ConversationID {
.direct(PeerHandle(
id: "peer:\(peerID.id)",
routingPeerID: peerID,
displayName: nil,
noisePublicKeyHex: nil,
nostrPublicKey: nil
))
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
}
}
extension ConversationStore {
/// All direct conversations' messages keyed by routing peer ID the
/// compat shape of the legacy `privateChats` dictionary. Values are the
/// conversations' backing arrays (COW), so building this is
/// O(#conversations), not O(#messages).
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
/// Values are the conversations' backing arrays (COW), so building this
/// is O(#conversations), not O(#messages).
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
messagesByPeerID.reserveCapacity(conversationsByID.count)
@@ -568,8 +606,8 @@ extension ConversationStore {
return messagesByPeerID
}
/// Unread direct conversations as routing peer IDs the compat shape of
/// the legacy `unreadPrivateMessages` set.
/// Unread direct conversations as routing peer IDs the shape
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
var peerIDs = Set<PeerID>()
for id in unreadConversations {
@@ -611,13 +649,11 @@ extension ConversationStore {
}
}
// MARK: - Migration step 3 compatibility (public timeline derived views)
// MARK: - Public timeline derived views
extension ConversationStore {
/// Removes a message by ID from whichever public (mesh/geohash)
/// conversation contains it the compat shape of the legacy
/// `PublicTimelineStore.removeMessage(withID:)`. Returns the removed
/// message, if any.
/// conversation contains it. Returns the removed message, if any.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for id in conversationIDs(forMessageID: messageID) {
+5 -5
View File
@@ -15,19 +15,19 @@ final class ConversationUIModel: ObservableObject {
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversationStore: LegacyConversationStore
private let conversations: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversationStore: LegacyConversationStore
conversations: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore
self.activeChannel = conversationStore.activeChannel
self.conversations = conversations
self.activeChannel = conversations.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
@@ -151,7 +151,7 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversationStore.$activeChannel
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
@@ -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 = ""
private let chatViewModel: ChatViewModel
private let conversationStore: LegacyConversationStore
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
@@ -45,13 +45,13 @@ final class PeerListModel: ObservableObject {
init(
chatViewModel: ChatViewModel,
conversationStore: LegacyConversationStore,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
@@ -122,7 +122,7 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
conversationStore.$unreadConversations
conversations.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
+67 -43
View File
@@ -2,69 +2,93 @@ import BitFoundation
import Combine
import Foundation
/// Feature model for private (direct) conversations.
///
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
/// returns the peer's conversation backing array (no mirror dictionary), and
/// the store's typed `changes` subject drives invalidation a change in the
/// SELECTED peer's conversation republishes this model, while appends to
/// other private chats only surface through the unread set. Direct
/// conversations are keyed by raw routing peer ID; the coordinators'
/// ephemeral/stable mirroring guarantees the selected peer's key always
/// holds the full timeline (see `ConversationID.directPeer`).
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: LegacyConversationStore
private let conversations: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: LegacyConversationStore) {
self.conversationStore = conversationStore
init(conversations: ConversationStore) {
self.conversations = conversations
self.selectedPeerID = conversations.selectedPrivatePeerID
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
bind()
refreshMessages()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? []
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
conversations.$selectedPrivatePeerID
.dropFirst()
.sink { [weak self] peerID in
self?.selectedPeerID = peerID
self?.refreshMessages()
guard let self, self.selectedPeerID != peerID else { return }
self.selectedPeerID = peerID
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
conversations.changes
.sink { [weak self] change in
self?.apply(change)
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
selectedPeerID = conversationStore.selectedPrivatePeerID
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
}
private func refreshMessages() {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
var peerIDs = Set(nextMessagesByPeerID.keys)
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
peerIDs.insert(selectedPeerID)
}
private func apply(_ change: ConversationChange) {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id):
republishIfSelected(id)
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
nextMessagesByPeerID[peerID] = []
}
case .unreadChanged(let id, _):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
guard messagesByPeerID != nextMessagesByPeerID else { return }
messagesByPeerID = nextMessagesByPeerID
case .removed(let id):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
republishIfSelected(id)
case .migrated(let source, let destination):
guard isDirect(source) || isDirect(destination) else { return }
refreshUnreadPeerIDs()
republishIfSelected(source)
republishIfSelected(destination)
}
}
private func republishIfSelected(_ id: ConversationID) {
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
objectWillChange.send()
}
private func refreshUnreadPeerIDs() {
let next = conversations.unreadDirectRoutingPeerIDs()
guard unreadPeerIDs != next else { return }
unreadPeerIDs = next
}
private func isDirect(_ id: ConversationID) -> Bool {
if case .direct = id { return true }
return false
}
}
@@ -94,22 +118,22 @@ final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversationStore: LegacyConversationStore
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: LegacyConversationStore,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID
let initialPeerID = conversations.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
@@ -154,7 +178,7 @@ final class PrivateConversationModel: ObservableObject {
}
private func bind() {
conversationStore.$selectedPrivatePeerID
conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
@@ -198,7 +222,7 @@ final class PrivateConversationModel: ObservableObject {
}
private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID
selectedPeerID = conversations.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
+52 -18
View File
@@ -2,42 +2,76 @@ import BitFoundation
import Combine
import SwiftUI
/// Feature model for the active public (mesh/geohash) timeline.
///
/// Observes ONE `Conversation` object in the single-writer
/// `ConversationStore` the active channel's so appends to background
/// conversations (other geohashes, private chats) never invalidate it.
/// `messages` reads the observed conversation's backing array directly;
/// there is no mirror copy.
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
@Published private(set) var messages: [BitchatMessage] = []
private let conversationStore: LegacyConversationStore
/// The active public conversation's timeline.
var messages: [BitchatMessage] { activeConversation.messages }
private let conversations: ConversationStore
private var activeConversation: Conversation
private var activeConversationCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
init(conversationStore: LegacyConversationStore) {
self.activeChannel = conversationStore.activeChannel
self.conversationStore = conversationStore
init(conversations: ConversationStore) {
let channel = conversations.activeChannel
self.conversations = conversations
self.activeChannel = channel
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
observeActiveConversation()
bind()
refreshMessages()
}
private func bind() {
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
conversations.$activeChannel
.dropFirst()
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshMessages()
guard let self else { return }
self.activeChannel = channel
self.retargetActiveConversation(to: channel)
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
// The store replaces a conversation's object when it is removed
// (panic clear); retarget to the fresh instance so the observation
// never goes stale.
conversations.changes
.sink { [weak self] change in
guard let self,
case .removed(let id) = change,
id == self.activeConversation.id else { return }
self.retargetActiveConversation(to: self.activeChannel)
}
.store(in: &cancellables)
}
private func refreshMessages() {
let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
guard messages != nextMessages else { return }
messages = nextMessages
private func retargetActiveConversation(to channel: ChannelID) {
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
guard conversation !== activeConversation else {
// Same object (e.g. re-selected channel): keep the existing
// observation, but `messages` may still differ from what views
// last rendered, so republish.
objectWillChange.send()
return
}
objectWillChange.send()
activeConversation = conversation
observeActiveConversation()
}
private func observeActiveConversation() {
activeConversationCancellable = activeConversation.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
}
-1
View File
@@ -31,7 +31,6 @@ protocol CommandContextProvider: AnyObject {
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
+2 -3
View File
@@ -14,9 +14,8 @@ import SwiftUI
/// Manages private chat session policy (selection, read receipts,
/// consolidation). Message storage lives in the single-writer
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
/// `privateChats` / `unreadMessages` properties below are read-only compat
/// views derived from it (migration step 2 the manager shrinks to
/// read-receipt policy in step 5).
/// `privateChats` / `unreadMessages` properties below are read-only views
/// derived from it.
final class PrivateChatManager: ObservableObject {
@Published var selectedPeer: PeerID? = nil
-3
View File
@@ -51,9 +51,6 @@ enum TransportConfig {
static let nostrInboundEventLogInterval: Int = 100
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
@@ -13,7 +13,9 @@ import Foundation
protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state
var messages: [BitchatMessage] { get }
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Appends a private message via the single-writer store intent.
@@ -73,7 +75,7 @@ protocol ChatLifecycleContext: AnyObject {
}
extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateChats`, `unreadPrivateMessages`,
// `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
@@ -187,7 +189,7 @@ final class ChatLifecycleCoordinator {
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let messages = context.privateChats[peerID] ?? []
let messages = context.privateMessages(for: peerID)
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !context.sentReadReceipts.contains(message.id) else { continue }
@@ -253,14 +255,12 @@ final class ChatLifecycleCoordinator {
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
if let ephemeralMessages = context.privateChats[peerID] {
combined.append(contentsOf: ephemeralMessages)
}
combined.append(contentsOf: context.privateMessages(for: peerID))
if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] {
combined.append(contentsOf: stableMessages)
if noiseKeyHex != peerID {
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
}
}
@@ -25,7 +25,6 @@ protocol ChatMediaTransferContext: AnyObject {
func currentPublicSender() -> (name: String, peerID: PeerID)
// MARK: Message state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
@@ -52,7 +51,7 @@ protocol ChatMediaTransferContext: AnyObject {
extension ChatViewModel: ChatMediaTransferContext {
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
// `currentPublicSender()`, `privateChats`,
// `currentPublicSender()`,
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
// `addSystemMessage(_:)`, `notifyUIChanged()`,
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
@@ -18,6 +18,9 @@ import Foundation
protocol ChatPeerIdentityContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
@@ -229,7 +232,7 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func openMostRelevantPrivateChat() {
let unreadSorted = context.unreadPrivateMessages
.map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
.map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
@@ -563,7 +566,7 @@ private extension ChatPeerIdentityCoordinator {
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
if context.selectedPrivateChatPeer == oldPeerID {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if context.privateChats[oldPeerID] != nil {
} else if !context.privateMessages(for: oldPeerID).isEmpty {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
}
@@ -613,7 +616,7 @@ private extension ChatPeerIdentityCoordinator {
}
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
fallbackNickname: fallbackNickname,
@@ -642,3 +645,11 @@ private extension ChatPeerIdentityCoordinator {
}
}
}
/// Default for conforming test contexts that model chats as a dictionary;
/// `ChatViewModel` overrides with a store-direct lookup.
extension ChatPeerIdentityContext {
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
}
@@ -13,7 +13,9 @@ import Foundation
protocol ChatPeerListContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
@@ -37,7 +39,7 @@ protocol ChatPeerListContext: AnyObject {
}
extension ChatViewModel: ChatPeerListContext {
// `isConnected`, `privateChats`, `unreadPrivateMessages`,
// `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
@@ -148,11 +150,11 @@ private extension ChatPeerListCoordinator {
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty {
if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty {
continue
}
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty {
continue
}
@@ -15,6 +15,9 @@ import Foundation
protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
@@ -588,8 +591,8 @@ final class ChatPrivateConversationCoordinator {
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
let stableKeyHex = PeerID(hexData: peerNoiseKey)
let nostrMessages = context.privateMessages(for: stableKeyHex)
if stableKeyHex != peerID,
let nostrMessages = context.privateChats[stableKeyHex],
!nostrMessages.isEmpty {
// Store migration dedups by ID, keeps timestamp order, and
// removes the stable-key chat.
@@ -767,7 +770,7 @@ final class ChatPrivateConversationCoordinator {
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
let currentFingerprint = context.getFingerprint(for: peerID)
if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true {
if context.privateMessages(for: peerID).isEmpty {
// Chats migrated wholesale go through the store's
// `migrateConversation` intent; partially-migrated chats keep
// their non-recent tail, so the recent messages are copied in
@@ -876,3 +879,11 @@ final class ChatPrivateConversationCoordinator {
return false
}
}
/// Default for conforming test contexts that model chats as a dictionary;
/// `ChatViewModel` overrides with a store-direct lookup.
extension ChatPrivateConversationContext {
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
}
@@ -51,9 +51,8 @@ protocol ChatPublicConversationContext: AnyObject {
func queueGeohashSystemMessage(_ content: String)
// MARK: Private chats (block cleanup & message removal)
var privateChats: [PeerID: [BitchatMessage]] { get }
/// Removes the peer's chat entirely, including unread state
/// (single-writer store intent).
/// (single-writer store intent; no-op for unknown peers).
func removePrivateChat(_ peerID: PeerID)
/// Removes a message by ID from every private chat containing it,
/// dropping chats that become empty. Returns the removed message.
@@ -102,7 +101,7 @@ protocol ChatPublicConversationContext: AnyObject {
}
extension ChatViewModel: ChatPublicConversationContext {
// `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
// `unreadPrivateMessages`, `nostrKeyMapping`,
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
@@ -218,10 +217,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.removePublicMessages(fromGeohash: gh, where: predicate)
}
let conversationPeerID = PeerID(nostr_: hex)
if context.privateChats[conversationPeerID] != nil {
context.removePrivateChat(conversationPeerID)
}
// The store intent no-ops when no such chat exists.
context.removePrivateChat(PeerID(nostr_: hex))
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
@@ -15,7 +15,9 @@ protocol ChatTransportEventContext: AnyObject {
var isConnected: Bool { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get set }
/// Appends a private message via the single-writer store intent;
@@ -70,7 +72,7 @@ protocol ChatTransportEventContext: AnyObject {
}
extension ChatViewModel: ChatTransportEventContext {
// `isConnected`, `nickname`, `myPeerID`, `privateChats`,
// `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`,
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
// the inbound message handlers, `isPeerBlocked(_:)`,
// `parseMentions(from:)`, `resolveNickname(for:)`,
@@ -233,12 +235,10 @@ final class ChatTransportEventCoordinator {
)
}
if let messages = context.privateChats[peerID] {
let receiptIDs = messages
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
@@ -261,8 +261,9 @@ private extension ChatTransportEventCoordinator {
) {
let hadUnread = context.unreadPrivateMessages.contains(shortPeerID)
if let messages = context.privateChats[shortPeerID] {
for message in messages {
let shortPeerMessages = context.privateMessages(for: shortPeerID)
if !shortPeerMessages.isEmpty {
for message in shortPeerMessages {
// Rewrite senderPeerID to the stable key so read receipts
// keep working; store append dedups by ID and keeps order.
let migrated = BitchatMessage(
+40 -73
View File
@@ -120,11 +120,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Published Properties
/// Read-only derived view of the ACTIVE public channel's conversation in
/// the single-writer `ConversationStore` (migration step 3 shim; views
/// observe `Conversation` objects directly in step 5). Hot for rendering,
/// so the array is cached and invalidated from the store's `changes`
/// subject (filtered to the active conversation) and on channel switches.
/// `objectWillChange` fires on every store change via the sink in `init`.
/// the single-writer `ConversationStore`. SwiftUI renders through
/// `PublicChatModel` (which observes the `Conversation` object directly);
/// this view serves the coordinators/commands that need "the visible
/// timeline" plus tests. Hot enough that the array is cached and
/// invalidated from the store's `changes` subject (filtered to the
/// active conversation) and on channel switches. `objectWillChange`
/// fires on every store change via the sink in `init`.
@MainActor
var messages: [BitchatMessage] {
if let cached = visibleMessagesCache { return cached }
@@ -180,13 +182,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
@Published var allPeers: [BitchatPeer] = []
/// Read-only compat view of all direct conversations in the new
/// `ConversationStore`, keyed by routing peer ID (migration step 2 shim;
/// views/feature models observe `Conversation` objects directly in
/// step 5). All mutations go through the private-chat intent ops below.
/// Rebuilt per access O(#conversations) thanks to COW message arrays;
/// measured equal to a change-invalidated cache on
/// `pipeline.privateIngest`, so the simpler form wins.
/// Read-only derived view of all direct conversations in the
/// `ConversationStore`, keyed by routing peer ID. Serves the coordinator
/// reads that genuinely need the whole dictionary (migration scans,
/// unread resolution); simple per-peer reads go through
/// `privateMessages(for:)` instead. All mutations go through the
/// private-chat intent ops below. Rebuilt per access
/// O(#conversations) thanks to COW message arrays; measured equal to a
/// change-invalidated cache on `pipeline.privateIngest`, so the simpler
/// form wins.
@MainActor
var privateChats: [PeerID: [BitchatMessage]] {
conversations.directMessagesByRoutingPeerID()
@@ -203,9 +207,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
synchronizeConversationSelectionStore()
}
}
/// Read-only compat view of the store's unread direct conversations
/// (migration step 2 shim). Mutate via `markPrivateChatUnread(_:)` /
/// `markPrivateChatRead(_:)`.
/// Read-only derived view of the store's unread direct conversations.
/// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`.
@MainActor
var unreadPrivateMessages: Set<PeerID> {
conversations.unreadDirectRoutingPeerIDs()
@@ -291,15 +294,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let meshService: Transport
let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol
let conversationStore: LegacyConversationStore
/// Single source of truth for conversation message state
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
/// through, mirroring the legacy store's wiring.
/// through.
let conversations: ConversationStore
/// Keeps `LegacyConversationStore` fed from `conversations` while feature
/// models still read Legacy (DELETE IN STEP 5).
private var legacyStoreBridge: LegacyConversationStoreBridge?
let identityResolver: IdentityResolver
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let locationManager: LocationChannelManager
@@ -310,12 +308,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID {
get { conversationStore.activeChannel }
get { conversations.activeChannel }
set {
guard conversationStore.activeChannel != newValue else { return }
conversationStore.setActiveChannel(newValue)
guard conversations.activeChannel != newValue else { return }
conversations.setActiveChannel(newValue)
visibleMessagesCache = nil
synchronizeConversationSelectionStore()
objectWillChange.send()
}
}
@@ -550,7 +547,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// The sole mutation paths for private (direct) message state. Each op
// forwards to the single-writer `ConversationStore`
// (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` /
// `unreadPrivateMessages` shims above are derived from the same store.
// `unreadPrivateMessages` views above are derived from the same store.
/// Appends a private message in timestamp order. Returns `false` when a
/// message with the same ID is already in that chat (O(1) dedup via the
@@ -610,6 +607,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID))
}
/// A single private chat's timeline, read straight from the store
/// an O(1) lookup that skips the `privateChats` dictionary build. The
/// context protocols' simple per-peer reads dispatch here.
@MainActor
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
/// `true` when any private chat contains a message with `messageID`
/// (O(1) per conversation via the store's ID indexes).
@MainActor
@@ -724,23 +729,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
conversationStore: LegacyConversationStore? = nil,
conversations: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let conversationStore = conversationStore ?? LegacyConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
self.init(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
conversationStore: conversationStore,
conversations: conversations,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager
@@ -755,16 +754,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
transport: Transport,
conversationStore: LegacyConversationStore? = nil,
conversations: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let conversationStore = conversationStore ?? LegacyConversationStore()
let conversations = conversations ?? ConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
let services = ChatViewModelServiceBundle(
@@ -777,9 +772,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.keychain = keychain
self.idBridge = idBridge
self.identityManager = identityManager
self.conversationStore = conversationStore
self.conversations = conversations
self.identityResolver = identityResolver
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.locationManager = locationManager
@@ -793,21 +786,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.publicMessagePipeline = services.publicMessagePipeline
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts()
// Keep the legacy store fed from the new store until the feature
// models cut over (migration step 5).
self.legacyStoreBridge = LegacyConversationStoreBridge(
store: conversations,
legacyStore: conversationStore,
identityResolver: identityResolver
)
// Republish on every store change so SwiftUI observers of the
// view model refresh. This replaces the UI-update role of the old
// `PrivateChatManager.@Published` dictionaries and the old
// `@Published var messages` (their debounced Legacy synchronization
// sinks are gone; the bridge above feeds Legacy instead). Changes
// touching the ACTIVE public conversation also invalidate the
// derived `messages` cache before observers re-read it.
// `@Published var messages`. Changes touching the ACTIVE public
// conversation also invalidate the derived `messages` cache before
// observers re-read it.
conversations.changes
.sink { [weak self] change in
guard let self else { return }
@@ -819,7 +803,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
.store(in: &cancellables)
ChatViewModelBootstrapper(viewModel: self).configure()
initializeConversationStore()
synchronizeConversationSelectionStore()
}
// MARK: - Deinitialization
@@ -1156,7 +1140,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
bleService.resetIdentityForPanic(currentNickname: nickname)
}
initializeConversationStore()
synchronizeConversationSelectionStore()
// No need to force UserDefaults synchronization
@@ -1278,28 +1262,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Handling
@MainActor
func initializeConversationStore() {
conversationStore.setActiveChannel(activeChannel)
synchronizeConversationSelectionStore()
}
/// Full Legacy-store resynchronization from the new `ConversationStore`.
/// Needed when `IdentityResolver` learns new peer associations, which can
/// re-key a direct conversation's canonical handle in Legacy
/// (DELETE IN STEP 5 with the bridge).
@MainActor
func resynchronizeLegacyPrivateConversations() {
legacyStoreBridge?.resynchronizeAll()
}
/// Pushes the private-chat manager's selection into the store, which
/// derives `selectedConversationID` from it and the active channel.
@MainActor
func synchronizeConversationSelectionStore() {
conversationStore.setSelectedPeerID(
privateChatManager.selectedPeer,
activeChannel: activeChannel,
identityResolver: identityResolver
)
conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer)
}
/// Invalidates the derived `messages` cache and notifies observers.
@@ -90,10 +90,9 @@ private extension ChatViewModelBootstrapper {
}
.store(in: &viewModel.cancellables)
// Private message state now flows: store intent
// `ConversationStore.changes` `LegacyConversationStoreBridge` (and
// the ChatViewModel shim-cache sink), so the old `$privateChats` /
// `$unreadMessages` debounced synchronization sinks are gone.
// Private message state flows through the single-writer
// `ConversationStore` intents and its `changes` subject; only the
// selection still originates in `PrivateChatManager`.
viewModel.privateChatManager.$selectedPeer
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
@@ -159,7 +158,6 @@ private extension ChatViewModelBootstrapper {
guard let viewModel else { return }
viewModel.allPeers = peers
viewModel.identityResolver.register(peers: peers)
var uniquePeers: [PeerID: BitchatPeer] = [:]
for peer in peers {
@@ -178,9 +176,6 @@ private extension ChatViewModelBootstrapper {
viewModel.updatePrivateChatPeerIfNeeded()
}
// Peer registrations can change a conversation's
// canonical handle in the legacy store; re-key it.
viewModel.resynchronizeLegacyPrivateConversations()
viewModel.synchronizeConversationSelectionStore()
}
}
@@ -81,7 +81,7 @@ struct CommandSuggestionsView: View {
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
let locationChannelsModel = LocationChannelsModel()
@@ -76,12 +76,12 @@ struct TextMessageView: View {
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
Group {