mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
/// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves
|
/// ephemeral↔stable 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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]] = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,39 @@
|
|||||||
# Conversation Store: Single Source of Truth
|
# Conversation Store: Single Source of Truth
|
||||||
|
|
||||||
**Status:** Steps 1–4 implemented (additive store, private cutover, public
|
**Status: migration complete (steps 1–5).** `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).
|
||||||
|
|||||||
Reference in New Issue
Block a user