mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:05:20 +00:00
Add single-writer ConversationStore core (additive)
Per-conversation ObservableObjects with O(1) dedup via an incrementally maintained message-ID index, binary-search timestamp insertion, folded cap policies, a no-downgrade delivery rule, and a typed change subject. All mutation flows through store intents (conversation mutators are fileprivate). The previous store is renamed LegacyConversationStore pending deletion in step 5. 16 behavioral tests including per- conversation publish isolation; store.append benchmarks at ~144k messages/sec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -197,7 +197,7 @@ final class IdentityResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ConversationStore: ObservableObject {
|
final class LegacyConversationStore: ObservableObject {
|
||||||
@Published private(set) var activeChannel: ChannelID = .mesh
|
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||||
@Published private(set) var selectedPrivatePeerID: PeerID?
|
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||||
@Published private(set) var selectedConversationID: ConversationID = .mesh
|
@Published private(set) var selectedConversationID: ConversationID = .mesh
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import AppKit
|
|||||||
final class AppRuntime: ObservableObject {
|
final class AppRuntime: ObservableObject {
|
||||||
let chatViewModel: ChatViewModel
|
let chatViewModel: ChatViewModel
|
||||||
let events = AppEventStream()
|
let events = AppEventStream()
|
||||||
let conversationStore: ConversationStore
|
let conversationStore: LegacyConversationStore
|
||||||
let peerIdentityStore: PeerIdentityStore
|
let peerIdentityStore: PeerIdentityStore
|
||||||
let locationPresenceStore: LocationPresenceStore
|
let locationPresenceStore: LocationPresenceStore
|
||||||
let publicChatModel: PublicChatModel
|
let publicChatModel: PublicChatModel
|
||||||
@@ -43,7 +43,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
let identityResolver = IdentityResolver()
|
let identityResolver = IdentityResolver()
|
||||||
let conversationStore = ConversationStore()
|
let conversationStore = LegacyConversationStore()
|
||||||
let peerIdentityStore = PeerIdentityStore()
|
let peerIdentityStore = PeerIdentityStore()
|
||||||
let locationPresenceStore = LocationPresenceStore()
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
let locationManager = LocationChannelManager.shared
|
let locationManager = LocationChannelManager.shared
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
//
|
||||||
|
// ConversationStore.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Single source of truth for conversation message state (see
|
||||||
|
// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per
|
||||||
|
// `ConversationID`; all mutations flow through the store's intent API and
|
||||||
|
// every mutation emits a `ConversationChange` after state is consistent.
|
||||||
|
//
|
||||||
|
// During migration the previous replace-based store lives on as
|
||||||
|
// `LegacyConversationStore` (AppArchitecture.swift) and is deleted in the
|
||||||
|
// final migration step.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Conversation
|
||||||
|
|
||||||
|
/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`).
|
||||||
|
///
|
||||||
|
/// Publishing granularity is per conversation: views observe ONE
|
||||||
|
/// `Conversation` object, so an append to chat A never invalidates observers
|
||||||
|
/// of chat B.
|
||||||
|
///
|
||||||
|
/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent
|
||||||
|
/// API may mutate a conversation, keeping the store the sole writer.
|
||||||
|
@MainActor
|
||||||
|
final class Conversation: ObservableObject, Identifiable {
|
||||||
|
let id: ConversationID
|
||||||
|
/// Maximum retained messages; oldest are trimmed on overflow.
|
||||||
|
let cap: Int
|
||||||
|
|
||||||
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
|
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||||
|
/// delivery-status lookup. Kept in sync on every mutation:
|
||||||
|
/// - tail append: single insert
|
||||||
|
/// - out-of-order insert: suffix reindex from the insertion point
|
||||||
|
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||||
|
/// rebuild does not change the asymptotics, and trim only happens once
|
||||||
|
/// the cap (1337) is reached. Simple and correct beats the
|
||||||
|
/// offset-tracking alternative here.
|
||||||
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
|
|
||||||
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
|
self.id = id
|
||||||
|
self.cap = max(1, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Reads
|
||||||
|
|
||||||
|
func containsMessage(withID messageID: String) -> Bool {
|
||||||
|
indexByMessageID[messageID] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
|
return messages[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Store-internal mutations
|
||||||
|
|
||||||
|
fileprivate enum UpsertOutcome {
|
||||||
|
case appended
|
||||||
|
case updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a message in timestamp order, deduplicating by message ID.
|
||||||
|
/// Fast path appends when the timestamp is >= the current tail;
|
||||||
|
/// otherwise a binary search finds the upper-bound insertion point so
|
||||||
|
/// arrival order is preserved among equal timestamps.
|
||||||
|
/// Returns `false` if a message with the same ID already exists.
|
||||||
|
fileprivate func insert(_ message: BitchatMessage) -> Bool {
|
||||||
|
guard indexByMessageID[message.id] == nil else { return false }
|
||||||
|
|
||||||
|
if let last = messages.last, message.timestamp < last.timestamp {
|
||||||
|
let index = insertionIndex(for: message.timestamp)
|
||||||
|
messages.insert(message, at: index)
|
||||||
|
reindex(from: index)
|
||||||
|
} else {
|
||||||
|
messages.append(message)
|
||||||
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trimIfNeeded()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace-or-append by message ID. An existing message keeps its
|
||||||
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
|
if let index = indexByMessageID[message.id] {
|
||||||
|
messages[index] = message
|
||||||
|
return .updated
|
||||||
|
}
|
||||||
|
_ = insert(message)
|
||||||
|
return .appended
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a delivery status keyed by message ID, honoring the
|
||||||
|
/// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`):
|
||||||
|
/// equal statuses are skipped, and `.read` is never downgraded to
|
||||||
|
/// `.delivered` or `.sent`.
|
||||||
|
/// Returns `true` when the status was applied.
|
||||||
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
|
let message = messages[index]
|
||||||
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
|
message.deliveryStatus = status
|
||||||
|
// BitchatMessage is a reference type; write back through the
|
||||||
|
// subscript so the @Published wrapper emits.
|
||||||
|
messages[index] = message
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
fileprivate func setUnread(_ unread: Bool) -> Bool {
|
||||||
|
guard isUnread != unread else { return false }
|
||||||
|
isUnread = unread
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func clearMessages() {
|
||||||
|
messages.removeAll()
|
||||||
|
indexByMessageID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Internals
|
||||||
|
|
||||||
|
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
|
||||||
|
guard let current else { return false }
|
||||||
|
if current == new { return true }
|
||||||
|
|
||||||
|
switch (current, new) {
|
||||||
|
case (.read, .delivered), (.read, .sent):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upper-bound binary search: first index whose timestamp is strictly
|
||||||
|
/// greater than `timestamp`, so equal-timestamp messages keep arrival
|
||||||
|
/// order.
|
||||||
|
private func insertionIndex(for timestamp: Date) -> Int {
|
||||||
|
var low = 0
|
||||||
|
var high = messages.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high) / 2
|
||||||
|
if messages[mid].timestamp <= timestamp {
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reindex(from start: Int) {
|
||||||
|
for index in start..<messages.count {
|
||||||
|
indexByMessageID[messages[index].id] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trimIfNeeded() {
|
||||||
|
guard messages.count > cap else { return }
|
||||||
|
let overflow = messages.count - cap
|
||||||
|
for message in messages.prefix(overflow) {
|
||||||
|
indexByMessageID.removeValue(forKey: message.id)
|
||||||
|
}
|
||||||
|
messages.removeFirst(overflow)
|
||||||
|
reindex(from: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ConversationChange
|
||||||
|
|
||||||
|
/// Typed mutation events for non-UI consumers (delivery tracking,
|
||||||
|
/// notifications, sync) that need "something changed in conversation X"
|
||||||
|
/// without subscribing to whole message arrays. Emitted on the store's
|
||||||
|
/// `changes` subject AFTER the corresponding state is consistent.
|
||||||
|
enum ConversationChange {
|
||||||
|
case appended(ConversationID, BitchatMessage)
|
||||||
|
case updated(ConversationID, messageID: String)
|
||||||
|
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
|
||||||
|
case cleared(ConversationID)
|
||||||
|
case removed(ConversationID)
|
||||||
|
case migrated(from: ConversationID, to: ConversationID)
|
||||||
|
case unreadChanged(ConversationID, isUnread: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ConversationStore
|
||||||
|
|
||||||
|
/// Sole writer and sole holder of conversation message state. All mutations
|
||||||
|
/// go through the intent API below; backing collections are `private(set)`.
|
||||||
|
/// Reads are synchronous — writers and readers share the main actor, so
|
||||||
|
/// after an intent returns every observer sees the result.
|
||||||
|
@MainActor
|
||||||
|
final class ConversationStore: ObservableObject {
|
||||||
|
/// Conversation creation order; published so list-style consumers can
|
||||||
|
/// observe conversations appearing/disappearing without rebuilding from
|
||||||
|
/// the dictionary.
|
||||||
|
@Published private(set) var conversationIDs: [ConversationID] = []
|
||||||
|
@Published private(set) var selectedConversationID: ConversationID?
|
||||||
|
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||||
|
|
||||||
|
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
||||||
|
|
||||||
|
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||||
|
|
||||||
|
// MARK: Intent API
|
||||||
|
|
||||||
|
/// Returns the conversation for `id`, creating it (with the cap policy
|
||||||
|
/// for its kind) on first access.
|
||||||
|
@discardableResult
|
||||||
|
func conversation(for id: ConversationID) -> Conversation {
|
||||||
|
if let existing = conversationsByID[id] {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let conversation = Conversation(id: id, cap: Self.cap(for: id))
|
||||||
|
conversationsByID[id] = conversation
|
||||||
|
conversationIDs.append(id)
|
||||||
|
return conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a message in timestamp order. Returns `false` (and emits
|
||||||
|
/// nothing) if a message with the same ID is already present.
|
||||||
|
@discardableResult
|
||||||
|
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
guard conversation.insert(message) else { return false }
|
||||||
|
changes.send(.appended(id, message))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace-or-append by message ID (media progress, edits).
|
||||||
|
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
switch conversation.upsert(message) {
|
||||||
|
case .appended:
|
||||||
|
changes.send(.appended(id, message))
|
||||||
|
case .updated:
|
||||||
|
changes.send(.updated(id, messageID: message.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a delivery status keyed by message ID. Returns `false` when
|
||||||
|
/// the message is unknown or the update would downgrade the status
|
||||||
|
/// (read beats delivered beats sent).
|
||||||
|
@discardableResult
|
||||||
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool {
|
||||||
|
guard let conversation = conversationsByID[id],
|
||||||
|
conversation.applyDeliveryStatus(status, forMessageID: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(_ id: ConversationID) {
|
||||||
|
guard unreadConversations.contains(id) else { return }
|
||||||
|
unreadConversations.remove(id)
|
||||||
|
conversationsByID[id]?.setUnread(false)
|
||||||
|
changes.send(.unreadChanged(id, isUnread: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func markUnread(_ id: ConversationID) {
|
||||||
|
guard !unreadConversations.contains(id) else { return }
|
||||||
|
let conversation = conversation(for: id)
|
||||||
|
unreadConversations.insert(id)
|
||||||
|
conversation.setUnread(true)
|
||||||
|
changes.send(.unreadChanged(id, isUnread: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a conversation (creating it if needed) or clears the
|
||||||
|
/// selection with `nil`.
|
||||||
|
func select(_ id: ConversationID?) {
|
||||||
|
if let id {
|
||||||
|
conversation(for: id)
|
||||||
|
}
|
||||||
|
guard selectedConversationID != id else { return }
|
||||||
|
selectedConversationID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves all messages from `source` into `destination` (the
|
||||||
|
/// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves
|
||||||
|
/// timestamp order, carries unread state over, and hands off the
|
||||||
|
/// selection — mirroring `ChatPrivateConversationCoordinator`'s
|
||||||
|
/// migration semantics. The source conversation is removed. Emits a
|
||||||
|
/// single `.migrated(from:to:)` once the whole move is consistent.
|
||||||
|
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
|
||||||
|
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
|
||||||
|
|
||||||
|
let destinationConversation = conversation(for: destination)
|
||||||
|
for message in sourceConversation.messages {
|
||||||
|
_ = destinationConversation.insert(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasUnread = unreadConversations.contains(source)
|
||||||
|
let wasSelected = selectedConversationID == source
|
||||||
|
|
||||||
|
conversationsByID.removeValue(forKey: source)
|
||||||
|
conversationIDs.removeAll { $0 == source }
|
||||||
|
unreadConversations.remove(source)
|
||||||
|
|
||||||
|
if wasUnread, !unreadConversations.contains(destination) {
|
||||||
|
unreadConversations.insert(destination)
|
||||||
|
destinationConversation.setUnread(true)
|
||||||
|
}
|
||||||
|
if wasSelected {
|
||||||
|
selectedConversationID = destination
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.send(.migrated(from: source, to: destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empties a conversation's timeline but keeps the conversation (and
|
||||||
|
/// its unread/selection state) alive.
|
||||||
|
func clear(_ id: ConversationID) {
|
||||||
|
guard let conversation = conversationsByID[id] else { return }
|
||||||
|
conversation.clearMessages()
|
||||||
|
changes.send(.cleared(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a conversation entirely, including unread state; clears the
|
||||||
|
/// selection if it pointed at the removed conversation.
|
||||||
|
func removeConversation(_ id: ConversationID) {
|
||||||
|
guard conversationsByID.removeValue(forKey: id) != nil else { return }
|
||||||
|
conversationIDs.removeAll { $0 == id }
|
||||||
|
unreadConversations.remove(id)
|
||||||
|
if selectedConversationID == id {
|
||||||
|
selectedConversationID = nil
|
||||||
|
}
|
||||||
|
changes.send(.removed(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
let removedIDs = conversationIDs
|
||||||
|
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
|
||||||
|
|
||||||
|
conversationsByID.removeAll()
|
||||||
|
conversationIDs.removeAll()
|
||||||
|
unreadConversations.removeAll()
|
||||||
|
if selectedConversationID != nil {
|
||||||
|
selectedConversationID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in removedIDs {
|
||||||
|
changes.send(.removed(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Internals
|
||||||
|
|
||||||
|
private static func cap(for id: ConversationID) -> Int {
|
||||||
|
switch id {
|
||||||
|
case .mesh:
|
||||||
|
return TransportConfig.meshTimelineCap
|
||||||
|
case .geohash:
|
||||||
|
return TransportConfig.geoTimelineCap
|
||||||
|
case .direct:
|
||||||
|
return TransportConfig.privateChatCap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,14 +15,14 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private let privateConversationModel: PrivateConversationModel
|
private let privateConversationModel: PrivateConversationModel
|
||||||
private let conversationStore: ConversationStore
|
private let conversationStore: LegacyConversationStore
|
||||||
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: ConversationStore
|
conversationStore: LegacyConversationStore
|
||||||
) {
|
) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.privateConversationModel = privateConversationModel
|
self.privateConversationModel = privateConversationModel
|
||||||
|
|||||||
@@ -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: ConversationStore
|
private let conversationStore: LegacyConversationStore
|
||||||
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,7 +45,7 @@ final class PeerListModel: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
conversationStore: ConversationStore,
|
conversationStore: LegacyConversationStore,
|
||||||
locationChannelsModel: LocationChannelsModel? = nil,
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
peerIdentityStore: PeerIdentityStore? = nil,
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
locationPresenceStore: LocationPresenceStore? = nil
|
locationPresenceStore: LocationPresenceStore? = nil
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ final class PrivateInboxModel: ObservableObject {
|
|||||||
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||||
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
private let conversationStore: ConversationStore
|
private let conversationStore: LegacyConversationStore
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(conversationStore: ConversationStore) {
|
init(conversationStore: LegacyConversationStore) {
|
||||||
self.conversationStore = conversationStore
|
self.conversationStore = conversationStore
|
||||||
|
|
||||||
bind()
|
bind()
|
||||||
@@ -94,14 +94,14 @@ 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: ConversationStore
|
private let conversationStore: LegacyConversationStore
|
||||||
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: ConversationStore,
|
conversationStore: LegacyConversationStore,
|
||||||
locationChannelsModel: LocationChannelsModel? = nil,
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
peerIdentityStore: PeerIdentityStore? = nil
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ final class PublicChatModel: ObservableObject {
|
|||||||
@Published private(set) var activeChannel: ChannelID
|
@Published private(set) var activeChannel: ChannelID
|
||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
|
|
||||||
private let conversationStore: ConversationStore
|
private let conversationStore: LegacyConversationStore
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(conversationStore: ConversationStore) {
|
init(conversationStore: LegacyConversationStore) {
|
||||||
self.activeChannel = conversationStore.activeChannel
|
self.activeChannel = conversationStore.activeChannel
|
||||||
self.conversationStore = conversationStore
|
self.conversationStore = conversationStore
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ 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: ConversationStore
|
let conversationStore: LegacyConversationStore
|
||||||
let identityResolver: IdentityResolver
|
let identityResolver: IdentityResolver
|
||||||
let peerIdentityStore: PeerIdentityStore
|
let peerIdentityStore: PeerIdentityStore
|
||||||
let locationPresenceStore: LocationPresenceStore
|
let locationPresenceStore: LocationPresenceStore
|
||||||
@@ -532,13 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
conversationStore: ConversationStore? = nil,
|
conversationStore: LegacyConversationStore? = nil,
|
||||||
identityResolver: IdentityResolver? = 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 ?? ConversationStore()
|
let conversationStore = conversationStore ?? LegacyConversationStore()
|
||||||
let identityResolver = identityResolver ?? IdentityResolver()
|
let identityResolver = identityResolver ?? IdentityResolver()
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
@@ -561,13 +561,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
conversationStore: ConversationStore? = nil,
|
conversationStore: LegacyConversationStore? = nil,
|
||||||
identityResolver: IdentityResolver? = 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 ?? ConversationStore()
|
let conversationStore = conversationStore ?? LegacyConversationStore()
|
||||||
let identityResolver = identityResolver ?? IdentityResolver()
|
let identityResolver = identityResolver ?? IdentityResolver()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
|
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
|
||||||
|
|||||||
@@ -146,10 +146,10 @@ struct AppArchitectureTests {
|
|||||||
#expect(Set([first, second]).count == 1)
|
#expect(Set([first, second]).count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore normalizes timeline ordering and duplicates")
|
@Test("LegacyConversationStore normalizes timeline ordering and duplicates")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreNormalizesMessages() {
|
func conversationStoreNormalizesMessages() {
|
||||||
let store = ConversationStore()
|
let store = LegacyConversationStore()
|
||||||
let older = BitchatMessage(
|
let older = BitchatMessage(
|
||||||
id: "m1",
|
id: "m1",
|
||||||
sender: "alice",
|
sender: "alice",
|
||||||
@@ -191,10 +191,10 @@ struct AppArchitectureTests {
|
|||||||
#expect(messages.last?.content == "second-updated")
|
#expect(messages.last?.content == "second-updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore tracks unread direct conversations with canonical IDs")
|
@Test("LegacyConversationStore tracks unread direct conversations with canonical IDs")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreTracksUnreadDirectConversations() {
|
func conversationStoreTracksUnreadDirectConversations() {
|
||||||
let store = ConversationStore()
|
let store = LegacyConversationStore()
|
||||||
let resolver = IdentityResolver()
|
let resolver = IdentityResolver()
|
||||||
let peerID = PeerID(str: "peer-1")
|
let peerID = PeerID(str: "peer-1")
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -226,10 +226,10 @@ struct AppArchitectureTests {
|
|||||||
#expect(!store.unreadConversations.contains(conversationID))
|
#expect(!store.unreadConversations.contains(conversationID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore tracks the selected app conversation context")
|
@Test("LegacyConversationStore tracks the selected app conversation context")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreTracksSelectedConversationContext() {
|
func conversationStoreTracksSelectedConversationContext() {
|
||||||
let store = ConversationStore()
|
let store = LegacyConversationStore()
|
||||||
let resolver = IdentityResolver()
|
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")
|
||||||
@@ -268,10 +268,10 @@ struct AppArchitectureTests {
|
|||||||
#expect(store.selectedConversationID == ConversationID.mesh)
|
#expect(store.selectedConversationID == ConversationID.mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore exposes direct conversations by the latest routing peer ID")
|
@Test("LegacyConversationStore exposes direct conversations by the latest routing peer ID")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
|
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
|
||||||
let store = ConversationStore()
|
let store = LegacyConversationStore()
|
||||||
let resolver = IdentityResolver()
|
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")
|
||||||
@@ -334,10 +334,10 @@ struct AppArchitectureTests {
|
|||||||
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
|
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("PrivateInboxModel mirrors direct message state from ConversationStore")
|
@Test("PrivateInboxModel mirrors direct message state from LegacyConversationStore")
|
||||||
@MainActor
|
@MainActor
|
||||||
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
|
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
|
||||||
let store = ConversationStore()
|
let store = LegacyConversationStore()
|
||||||
let resolver = IdentityResolver()
|
let resolver = IdentityResolver()
|
||||||
let inboxModel = PrivateInboxModel(conversationStore: store)
|
let inboxModel = PrivateInboxModel(conversationStore: store)
|
||||||
let messagePeerID = PeerID(str: "peer-1")
|
let messagePeerID = PeerID(str: "peer-1")
|
||||||
@@ -383,7 +383,7 @@ struct AppArchitectureTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func appChromeModelMirrorsNicknameAndUnreadState() async {
|
func appChromeModelMirrorsNicknameAndUnreadState() async {
|
||||||
let viewModel = makeArchitectureViewModel()
|
let viewModel = makeArchitectureViewModel()
|
||||||
let conversationStore = ConversationStore()
|
let conversationStore = LegacyConversationStore()
|
||||||
let resolver = IdentityResolver()
|
let resolver = IdentityResolver()
|
||||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||||
@@ -414,7 +414,7 @@ struct AppArchitectureTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func appChromeModelOwnsPresentationState() {
|
func appChromeModelOwnsPresentationState() {
|
||||||
let viewModel = makeArchitectureViewModel()
|
let viewModel = makeArchitectureViewModel()
|
||||||
let conversationStore = ConversationStore()
|
let conversationStore = LegacyConversationStore()
|
||||||
let privateInboxModel = PrivateInboxModel(conversationStore: 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")
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
//
|
||||||
|
// ConversationStoreTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Tests for the new single-source-of-truth ConversationStore
|
||||||
|
// (docs/CONVERSATION-STORE-DESIGN.md): intent API, ordered insertion,
|
||||||
|
// dedup, caps, delivery-status rules, migration, unread state, change
|
||||||
|
// emission, and per-conversation publish isolation.
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeMessage(
|
||||||
|
id: String,
|
||||||
|
timestamp: TimeInterval,
|
||||||
|
content: String? = nil,
|
||||||
|
isPrivate: Bool = false,
|
||||||
|
deliveryStatus: DeliveryStatus? = nil
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: "alice",
|
||||||
|
content: content ?? "message \(id)",
|
||||||
|
timestamp: Date(timeIntervalSince1970: timestamp),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
recipientNickname: isPrivate ? "bob" : nil,
|
||||||
|
senderPeerID: PeerID(str: "peer-a"),
|
||||||
|
deliveryStatus: deliveryStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
||||||
|
.direct(PeerHandle(
|
||||||
|
id: "noise:\(suffix)",
|
||||||
|
routingPeerID: PeerID(str: "peer-\(suffix)"),
|
||||||
|
displayName: "peer \(suffix)",
|
||||||
|
noisePublicKeyHex: suffix,
|
||||||
|
nostrPublicKey: nil
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("ConversationStore")
|
||||||
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
|
// MARK: - Append, dedup, ordering
|
||||||
|
|
||||||
|
@Test("append dedups by message ID and reports duplicates")
|
||||||
|
@MainActor
|
||||||
|
func appendDedupsByMessageID() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
var received: [ConversationChange] = []
|
||||||
|
let cancellable = store.changes.sink { received.append($0) }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
#expect(store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh))
|
||||||
|
#expect(!store.append(makeMessage(id: "m1", timestamp: 2, content: "dup"), to: .mesh))
|
||||||
|
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
#expect(conversation.messages.count == 1)
|
||||||
|
#expect(conversation.messages.first?.content == "message m1")
|
||||||
|
#expect(conversation.containsMessage(withID: "m1"))
|
||||||
|
#expect(received.count == 1)
|
||||||
|
guard case .appended(.mesh, let message) = received.first else {
|
||||||
|
Issue.record("expected a single .appended change, got \(received)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(message.id == "m1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("out-of-order appends are inserted in timestamp order")
|
||||||
|
@MainActor
|
||||||
|
func outOfOrderInsertKeepsTimestampOrder() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "m0", timestamp: 5), to: .mesh)
|
||||||
|
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
#expect(conversation.messages.map(\.id) == ["m0", "m1", "m2", "m3"])
|
||||||
|
|
||||||
|
// The ID index must survive middle inserts: lookups by ID still
|
||||||
|
// resolve to the right message.
|
||||||
|
#expect(conversation.message(withID: "m2")?.timestamp == Date(timeIntervalSince1970: 20))
|
||||||
|
#expect(conversation.message(withID: "m0")?.timestamp == Date(timeIntervalSince1970: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("equal timestamps preserve arrival order")
|
||||||
|
@MainActor
|
||||||
|
func equalTimestampsPreserveArrivalOrder() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "first", timestamp: 10), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "second", timestamp: 10), to: .mesh)
|
||||||
|
// A late message with an equal timestamp lands after existing peers.
|
||||||
|
store.append(makeMessage(id: "late-tail", timestamp: 20), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "third", timestamp: 10), to: .mesh)
|
||||||
|
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
#expect(conversation.messages.map(\.id) == ["first", "second", "third", "late-tail"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Caps
|
||||||
|
|
||||||
|
@Test("cap trims oldest messages and keeps the ID index valid")
|
||||||
|
@MainActor
|
||||||
|
func capTrimsOldestAndKeepsIndexValid() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let cap = conversation.cap
|
||||||
|
#expect(cap == TransportConfig.meshTimelineCap)
|
||||||
|
|
||||||
|
let overflow = 3
|
||||||
|
for i in 0..<(cap + overflow) {
|
||||||
|
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(conversation.messages.count == cap)
|
||||||
|
#expect(conversation.messages.first?.id == "m\(overflow)")
|
||||||
|
#expect(conversation.messages.last?.id == "m\(cap + overflow - 1)")
|
||||||
|
|
||||||
|
// Trimmed messages left the index entirely…
|
||||||
|
for i in 0..<overflow {
|
||||||
|
#expect(!conversation.containsMessage(withID: "m\(i)"))
|
||||||
|
}
|
||||||
|
// …and re-appending a trimmed ID is allowed (no stale index entry).
|
||||||
|
#expect(store.append(makeMessage(id: "m0", timestamp: TimeInterval(cap + overflow)), to: .mesh))
|
||||||
|
|
||||||
|
// Surviving entries still resolve correctly after the trim reindex.
|
||||||
|
let probeID = "m\(cap / 2 + overflow)"
|
||||||
|
#expect(conversation.message(withID: probeID)?.id == probeID)
|
||||||
|
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
||||||
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Upsert
|
||||||
|
|
||||||
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
@MainActor
|
||||||
|
func upsertReplacesOrAppends() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
var received: [ConversationChange] = []
|
||||||
|
let cancellable = store.changes.sink { received.append($0) }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.upsertByID(makeMessage(id: "m1", timestamp: 10), in: .mesh)
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh)
|
||||||
|
store.upsertByID(makeMessage(id: "m1", timestamp: 10, content: "edited"), in: .mesh)
|
||||||
|
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
#expect(conversation.messages.map(\.id) == ["m1", "m2"])
|
||||||
|
#expect(conversation.message(withID: "m1")?.content == "edited")
|
||||||
|
|
||||||
|
#expect(received.count == 3)
|
||||||
|
guard case .appended(.mesh, let first) = received[0], first.id == "m1",
|
||||||
|
case .appended(.mesh, let second) = received[1], second.id == "m2",
|
||||||
|
case .updated(.mesh, let updatedID) = received[2], updatedID == "m1" else {
|
||||||
|
Issue.record("unexpected change sequence: \(received)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delivery status
|
||||||
|
|
||||||
|
@Test("setDeliveryStatus never downgrades read and skips equal statuses")
|
||||||
|
@MainActor
|
||||||
|
func deliveryStatusNoDowngrade() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let id = makeDirectConversationID("aa")
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1, isPrivate: true, deliveryStatus: .sending), to: id)
|
||||||
|
var statusChanges: [DeliveryStatus] = []
|
||||||
|
let cancellable = store.changes.sink { change in
|
||||||
|
if case .statusChanged(_, _, let status) = change {
|
||||||
|
statusChanges.append(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
let conversation = store.conversation(for: id)
|
||||||
|
let readStatus = DeliveryStatus.read(by: "bob", at: Date(timeIntervalSince1970: 100))
|
||||||
|
|
||||||
|
#expect(store.setDeliveryStatus(.sent, forMessageID: "m1", in: id))
|
||||||
|
// Equal status is a no-op.
|
||||||
|
#expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: id))
|
||||||
|
#expect(store.setDeliveryStatus(readStatus, forMessageID: "m1", in: id))
|
||||||
|
// Read beats delivered and sent: both downgrades are refused.
|
||||||
|
#expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "m1", in: id))
|
||||||
|
#expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: id))
|
||||||
|
#expect(conversation.message(withID: "m1")?.deliveryStatus == readStatus)
|
||||||
|
|
||||||
|
// Unknown message or conversation: refused, nothing emitted.
|
||||||
|
#expect(!store.setDeliveryStatus(.sent, forMessageID: "nope", in: id))
|
||||||
|
#expect(!store.setDeliveryStatus(.sent, forMessageID: "m1", in: .mesh))
|
||||||
|
|
||||||
|
#expect(statusChanges == [.sent, readStatus])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Unread state
|
||||||
|
|
||||||
|
@Test("markUnread and markRead keep the set and conversation flag consistent")
|
||||||
|
@MainActor
|
||||||
|
func unreadStateConsistency() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let id = makeDirectConversationID("bb")
|
||||||
|
var unreadChanges: [(ConversationID, Bool)] = []
|
||||||
|
let cancellable = store.changes.sink { change in
|
||||||
|
if case .unreadChanged(let conversationID, let isUnread) = change {
|
||||||
|
unreadChanges.append((conversationID, isUnread))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
// markRead on a never-unread conversation is a no-op.
|
||||||
|
store.markRead(id)
|
||||||
|
#expect(unreadChanges.isEmpty)
|
||||||
|
|
||||||
|
store.markUnread(id)
|
||||||
|
#expect(store.unreadConversations == [id])
|
||||||
|
#expect(store.conversation(for: id).isUnread)
|
||||||
|
// Idempotent: marking unread twice emits once.
|
||||||
|
store.markUnread(id)
|
||||||
|
|
||||||
|
store.markRead(id)
|
||||||
|
#expect(store.unreadConversations.isEmpty)
|
||||||
|
#expect(!store.conversation(for: id).isUnread)
|
||||||
|
|
||||||
|
#expect(unreadChanges.count == 2)
|
||||||
|
#expect(unreadChanges[0].0 == id && unreadChanges[0].1 == true)
|
||||||
|
#expect(unreadChanges[1].0 == id && unreadChanges[1].1 == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection
|
||||||
|
|
||||||
|
@Test("select creates the conversation and clears with nil")
|
||||||
|
@MainActor
|
||||||
|
func selectTracksConversation() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let id = makeDirectConversationID("cc")
|
||||||
|
|
||||||
|
store.select(id)
|
||||||
|
#expect(store.selectedConversationID == id)
|
||||||
|
#expect(store.conversationsByID[id] != nil)
|
||||||
|
#expect(store.conversationIDs == [id])
|
||||||
|
|
||||||
|
store.select(nil)
|
||||||
|
#expect(store.selectedConversationID == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Migration
|
||||||
|
|
||||||
|
@Test("migrateConversation moves messages, dedups, and preserves order")
|
||||||
|
@MainActor
|
||||||
|
func migrationMovesAndDedups() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let ephemeral = makeDirectConversationID("old")
|
||||||
|
let stable = makeDirectConversationID("new")
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 10), to: ephemeral)
|
||||||
|
store.append(makeMessage(id: "m3", timestamp: 30), to: ephemeral)
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 20), to: stable)
|
||||||
|
store.append(makeMessage(id: "m3", timestamp: 30, content: "already there"), to: stable)
|
||||||
|
|
||||||
|
var received: [ConversationChange] = []
|
||||||
|
let cancellable = store.changes.sink { received.append($0) }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.migrateConversation(from: ephemeral, to: stable)
|
||||||
|
|
||||||
|
let destination = store.conversation(for: stable)
|
||||||
|
#expect(destination.messages.map(\.id) == ["m1", "m2", "m3"])
|
||||||
|
// Existing copy wins the dedup.
|
||||||
|
#expect(destination.message(withID: "m3")?.content == "already there")
|
||||||
|
#expect(store.conversationsByID[ephemeral] == nil)
|
||||||
|
#expect(!store.conversationIDs.contains(ephemeral))
|
||||||
|
|
||||||
|
#expect(received.count == 1)
|
||||||
|
guard case .migrated(let from, let to) = received.first, from == ephemeral, to == stable else {
|
||||||
|
Issue.record("expected a single .migrated change, got \(received)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("migrateConversation hands off unread state and selection")
|
||||||
|
@MainActor
|
||||||
|
func migrationMovesUnreadAndSelection() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let ephemeral = makeDirectConversationID("old")
|
||||||
|
let stable = makeDirectConversationID("new")
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 10), to: ephemeral)
|
||||||
|
store.markUnread(ephemeral)
|
||||||
|
store.select(ephemeral)
|
||||||
|
|
||||||
|
store.migrateConversation(from: ephemeral, to: stable)
|
||||||
|
|
||||||
|
#expect(store.unreadConversations == [stable])
|
||||||
|
#expect(store.conversation(for: stable).isUnread)
|
||||||
|
#expect(store.selectedConversationID == stable)
|
||||||
|
|
||||||
|
// Migrating from a missing source or onto itself is a no-op.
|
||||||
|
store.migrateConversation(from: ephemeral, to: stable)
|
||||||
|
store.migrateConversation(from: stable, to: stable)
|
||||||
|
#expect(store.conversation(for: stable).messages.map(\.id) == ["m1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clear / remove
|
||||||
|
|
||||||
|
@Test("clear empties the timeline but keeps the conversation")
|
||||||
|
@MainActor
|
||||||
|
func clearKeepsConversation() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let id = makeDirectConversationID("dd")
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: id)
|
||||||
|
store.markUnread(id)
|
||||||
|
var received: [ConversationChange] = []
|
||||||
|
let cancellable = store.changes.sink { received.append($0) }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.clear(id)
|
||||||
|
|
||||||
|
let conversation = store.conversation(for: id)
|
||||||
|
#expect(conversation.messages.isEmpty)
|
||||||
|
#expect(!conversation.containsMessage(withID: "m1"))
|
||||||
|
#expect(store.conversationIDs.contains(id))
|
||||||
|
#expect(store.unreadConversations.contains(id))
|
||||||
|
// The ID index was cleared too: the same message can return.
|
||||||
|
#expect(store.append(makeMessage(id: "m1", timestamp: 2), to: id))
|
||||||
|
|
||||||
|
guard case .cleared(id) = received.first else {
|
||||||
|
Issue.record("expected .cleared first, got \(received)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("removeConversation drops messages, unread state, and selection")
|
||||||
|
@MainActor
|
||||||
|
func removeConversationDropsEverything() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let id = makeDirectConversationID("ee")
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: id)
|
||||||
|
store.markUnread(id)
|
||||||
|
store.select(id)
|
||||||
|
var received: [ConversationChange] = []
|
||||||
|
let cancellable = store.changes.sink { received.append($0) }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.removeConversation(id)
|
||||||
|
|
||||||
|
#expect(store.conversationsByID[id] == nil)
|
||||||
|
#expect(store.conversationIDs.isEmpty)
|
||||||
|
#expect(store.unreadConversations.isEmpty)
|
||||||
|
#expect(store.selectedConversationID == nil)
|
||||||
|
#expect(received.count == 1)
|
||||||
|
guard case .removed(id) = received.first else {
|
||||||
|
Issue.record("expected .removed, got \(received)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing again is a no-op.
|
||||||
|
store.removeConversation(id)
|
||||||
|
#expect(received.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("clearAll removes every conversation and emits removals")
|
||||||
|
@MainActor
|
||||||
|
func clearAllRemovesEverything() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let direct = makeDirectConversationID("ff")
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 2), to: direct)
|
||||||
|
store.markUnread(direct)
|
||||||
|
store.select(direct)
|
||||||
|
var removed: [ConversationID] = []
|
||||||
|
let cancellable = store.changes.sink { change in
|
||||||
|
if case .removed(let id) = change { removed.append(id) }
|
||||||
|
}
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.clearAll()
|
||||||
|
|
||||||
|
#expect(store.conversationsByID.isEmpty)
|
||||||
|
#expect(store.conversationIDs.isEmpty)
|
||||||
|
#expect(store.unreadConversations.isEmpty)
|
||||||
|
#expect(store.selectedConversationID == nil)
|
||||||
|
#expect(removed == [.mesh, direct])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Change emission
|
||||||
|
|
||||||
|
@Test("changes are emitted after state is consistent")
|
||||||
|
@MainActor
|
||||||
|
func changesEmittedAfterStateIsConsistent() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
var observedCountsAtEmission: [Int] = []
|
||||||
|
var observedUnreadAtEmission: [Bool] = []
|
||||||
|
let cancellable = store.changes.sink { change in
|
||||||
|
switch change {
|
||||||
|
case .appended(let id, let message):
|
||||||
|
// The appended message must already be visible at emission.
|
||||||
|
observedCountsAtEmission.append(store.conversation(for: id).messages.count)
|
||||||
|
#expect(store.conversation(for: id).containsMessage(withID: message.id))
|
||||||
|
case .unreadChanged(let id, let isUnread):
|
||||||
|
#expect(store.unreadConversations.contains(id) == isUnread)
|
||||||
|
observedUnreadAtEmission.append(isUnread)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 2), to: .mesh)
|
||||||
|
store.markUnread(.mesh)
|
||||||
|
store.markRead(.mesh)
|
||||||
|
|
||||||
|
#expect(observedCountsAtEmission == [1, 2])
|
||||||
|
#expect(observedUnreadAtEmission == [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("cap policy follows the conversation kind")
|
||||||
|
@MainActor
|
||||||
|
func capPolicyByKind() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
#expect(store.conversation(for: .mesh).cap == TransportConfig.meshTimelineCap)
|
||||||
|
#expect(store.conversation(for: .geohash("u4pruyd")).cap == TransportConfig.geoTimelineCap)
|
||||||
|
#expect(store.conversation(for: makeDirectConversationID("gg")).cap == TransportConfig.privateChatCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Publish isolation
|
||||||
|
|
||||||
|
@Test("appending to one conversation does not publish another")
|
||||||
|
@MainActor
|
||||||
|
func perConversationPublishIsolation() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let a = makeDirectConversationID("aa")
|
||||||
|
let b = makeDirectConversationID("bb")
|
||||||
|
let conversationA = store.conversation(for: a)
|
||||||
|
let conversationB = store.conversation(for: b)
|
||||||
|
|
||||||
|
var aWillChangeCount = 0
|
||||||
|
var bWillChangeCount = 0
|
||||||
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
conversationA.objectWillChange
|
||||||
|
.sink { aWillChangeCount += 1 }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
conversationB.objectWillChange
|
||||||
|
.sink { bWillChangeCount += 1 }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m1", timestamp: 1), to: a)
|
||||||
|
store.append(makeMessage(id: "m2", timestamp: 2), to: a)
|
||||||
|
store.setDeliveryStatus(.sent, forMessageID: "m1", in: a)
|
||||||
|
store.markUnread(a)
|
||||||
|
|
||||||
|
#expect(aWillChangeCount >= 4)
|
||||||
|
#expect(bWillChangeCount == 0)
|
||||||
|
|
||||||
|
store.append(makeMessage(id: "m3", timestamp: 3), to: b)
|
||||||
|
#expect(bWillChangeCount > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,7 +333,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
/// 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` → `privateChats` passthrough →
|
||||||
/// `PrivateChatManager`'s `@Published` dict → bootstrapper Combine sink →
|
/// `PrivateChatManager`'s `@Published` dict → bootstrapper Combine sink →
|
||||||
/// `Task.yield`-debounced `ConversationStore.synchronizePrivateChats`
|
/// `Task.yield`-debounced `LegacyConversationStore.synchronizePrivateChats`
|
||||||
/// (full-dict replace) → `PrivateInboxModel.refreshMessages` (full
|
/// (full-dict replace) → `PrivateInboxModel.refreshMessages` (full
|
||||||
/// rebuild). Measures wall time from first ingest until the store AND the
|
/// rebuild). Measures wall time from first ingest until the store AND the
|
||||||
/// feature model both reflect every message. The peer is not mesh-active
|
/// feature model both reflect every message. The peer is not mesh-active
|
||||||
@@ -370,14 +370,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 ConversationStore sync
|
// Drain the main queue until the debounced LegacyConversationStore sync
|
||||||
// ran and the PrivateInboxModel mirror caught up.
|
// ran and the PrivateInboxModel mirror caught up.
|
||||||
let consistent = spinMainRunLoop(timeout: 10) {
|
let consistent = spinMainRunLoop(timeout: 10) {
|
||||||
fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount
|
fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount
|
||||||
&& fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount
|
&& fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount
|
||||||
}
|
}
|
||||||
samples.append(Date().timeIntervalSince(start))
|
samples.append(Date().timeIntervalSince(start))
|
||||||
XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged")
|
XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged")
|
||||||
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
|
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +390,7 @@ 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,
|
||||||
/// `PublicTimelineStore` append, per-message full-array
|
/// `PublicTimelineStore` append, per-message full-array
|
||||||
/// `ConversationStore` sync) → `PublicMessagePipeline` timer-batched
|
/// `LegacyConversationStore` sync) → `PublicMessagePipeline` timer-batched
|
||||||
/// flush into `ChatViewModel.messages` → `PublicChatModel` mirror.
|
/// flush into `ChatViewModel.messages` → `PublicChatModel` mirror.
|
||||||
/// 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
|
||||||
@@ -450,6 +450,44 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 7. ConversationStore append (to-be architecture)
|
||||||
|
|
||||||
|
/// Core op of the new single-source-of-truth `ConversationStore`
|
||||||
|
/// (docs/CONVERSATION-STORE-DESIGN.md): 1000 appends into one
|
||||||
|
/// conversation, every 10th arriving out of order so the binary-search
|
||||||
|
/// insert path (plus suffix reindex) is part of the measured work.
|
||||||
|
/// Includes per-message ID-index dedup and the per-conversation
|
||||||
|
/// `@Published` array write.
|
||||||
|
func testConversationStoreAppend() {
|
||||||
|
let messageCount = 1000
|
||||||
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let messages: [BitchatMessage] = (0..<messageCount).map { i in
|
||||||
|
// Every 10th message is "late": its timestamp predates already
|
||||||
|
// appended messages, forcing the ordered-insert slow path.
|
||||||
|
let offset = (i % 10 == 9) ? Double(i) - 5.5 : Double(i)
|
||||||
|
return BitchatMessage(
|
||||||
|
id: "perf-store-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "store append message \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(offset),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var samples: [TimeInterval] = []
|
||||||
|
|
||||||
|
measure {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let start = Date()
|
||||||
|
for message in messages {
|
||||||
|
store.append(message, to: .mesh)
|
||||||
|
}
|
||||||
|
samples.append(Date().timeIntervalSince(start))
|
||||||
|
XCTAssertEqual(store.conversation(for: .mesh).messages.count, messageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
|
}
|
||||||
|
|
||||||
/// Spins the main run loop in small slices (draining main-queue tasks and
|
/// Spins the main run loop in small slices (draining main-queue tasks and
|
||||||
/// timers) until `condition` holds or `timeout` elapses.
|
/// timers) until `condition` holds or `timeout` elapses.
|
||||||
private func spinMainRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool {
|
private func spinMainRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool {
|
||||||
@@ -654,14 +692,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
|
||||||
/// `ConversationStore`, so end-to-end ingest benchmarks cover the full
|
/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full
|
||||||
/// current store-synchronization chain. Mirrors the construction used by
|
/// current store-synchronization 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: ConversationStore
|
let conversationStore: LegacyConversationStore
|
||||||
let privateInbox: PrivateInboxModel
|
let privateInbox: PrivateInboxModel
|
||||||
let publicChat: PublicChatModel
|
let publicChat: PublicChatModel
|
||||||
|
|
||||||
@@ -670,7 +708,7 @@ 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 = ConversationStore()
|
let conversationStore = LegacyConversationStore()
|
||||||
|
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.conversationStore = conversationStore
|
self.conversationStore = conversationStore
|
||||||
|
|||||||
Reference in New Issue
Block a user