Single-source-of-truth ConversationStore: 2-2.5x ingest, four stores and three sync bridges deleted (#1334)

Single-source-of-truth ConversationStore: 2-2.5x ingest, four stores and three sync bridges deleted
This commit is contained in:
jack
2026-06-11 13:19:24 +01:00
committed by GitHub
50 changed files with 3677 additions and 2366 deletions
+3 -284
View File
@@ -66,12 +66,12 @@ actor AppEventStream {
}
}
/// Identity key for a direct conversation. Equality and hashing use the
/// canonical `id` only; `routingPeerID` carries the transport-level peer ID
/// the conversation is keyed under (see `ConversationID.directPeer`).
struct PeerHandle: Sendable, Identifiable {
let id: String
let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
}
extension PeerHandle: Equatable {
@@ -100,284 +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 ConversationStore: 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))
}
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
}
}
+13 -12
View File
@@ -14,7 +14,10 @@ import AppKit
final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: ConversationStore
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
@@ -42,30 +45,28 @@ final class AppRuntime: ObservableObject {
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = ConversationStore()
let conversations = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
identityResolver: identityResolver,
conversations: conversations,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
self.publicChatModel = PublicChatModel(conversations: conversations)
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
@@ -77,11 +78,11 @@ final class AppRuntime: ObservableObject {
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore
conversations: conversations
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
@@ -218,7 +219,7 @@ final class AppRuntime: ObservableObject {
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID {
if conversations.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
+695
View File
@@ -0,0 +1,695 @@
//
// 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.
//
// The store also owns conversation selection: the active public channel and
// the selected private peer (the two UI selection axes) plus the derived
// `selectedConversationID`.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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]
}
/// All message IDs currently in this conversation (unordered).
var messageIDs: Dictionary<String, Int>.Keys {
indexByMessageID.keys
}
// MARK: Store-internal mutations
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
/// evicted by the cap so the store can keep its message-ID
/// conversation map exact.
fileprivate struct InsertResult {
let inserted: Bool
let trimmedMessageIDs: [String]
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
}
fileprivate enum UpsertOutcome {
case appended(trimmedMessageIDs: [String])
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.
/// Reports `inserted: false` if a message with the same ID already exists.
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
guard indexByMessageID[message.id] == nil else { return .duplicate }
if let last = messages.last, message.timestamp < last.timestamp {
let index = insertionIndex(for: message.timestamp)
messages.insert(message, at: index)
reindex(from: index)
} else {
messages.append(message)
indexByMessageID[message.id] = messages.count - 1
}
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
}
/// 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
}
let result = insert(message)
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
}
/// Applies a delivery status keyed by message ID, honoring the
/// no-downgrade rule (the SOLE enforcement point every delivery
/// update flows through the store): equal statuses are skipped, and
/// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
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
}
/// Republishes a message without changing state. Used for mirrored
/// copies that share a BitchatMessage instance: the first conversation's
/// status apply mutated the shared object, so this conversation's
/// observers still need an @Published emission to re-render.
@discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index]
return true
}
@discardableResult
fileprivate func setUnread(_ unread: Bool) -> Bool {
guard isUnread != unread else { return false }
isUnread = unread
return true
}
/// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID)
reindex(from: index)
return removed
}
/// Removes every message matching `predicate`. Returns the removed
/// message IDs (empty when nothing matched).
fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] {
var removedIDs: [String] = []
messages.removeAll { message in
guard predicate(message) else { return false }
removedIDs.append(message.id)
return true
}
guard !removedIDs.isEmpty else { return [] }
for id in removedIDs {
indexByMessageID.removeValue(forKey: id)
}
reindex(from: 0)
return removedIDs
}
fileprivate func clearMessages() {
messages.removeAll()
indexByMessageID.removeAll()
}
// 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
}
}
/// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] }
let overflow = messages.count - cap
let trimmedIDs = messages.prefix(overflow).map(\.id)
for id in trimmedIDs {
indexByMessageID.removeValue(forKey: id)
}
messages.removeFirst(overflow)
reindex(from: 0)
return trimmedIDs
}
}
// 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 messageRemoved(ConversationID, messageID: String)
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> = []
// MARK: Selection state
// The two UI selection axes: which public channel is active, and which
// private chat (if any) is open on top of it. `selectedConversationID`
// is derived: the open private chat wins, otherwise the active public
// channel's conversation. Mutate via `setActiveChannel` /
// `setSelectedPrivatePeer` only.
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
/// Store-level message-ID conversation-membership map for ID-only
/// lookups (delivery receipts arrive with a message ID, not a
/// conversation). Maintained incrementally at every mutation point
/// all mutation is centralized in the intent API below, so the map is
/// exact, never scanned or rebuilt.
///
/// The value is a `Set` because a private message can legitimately live
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
/// message into both the stable-key and ephemeral-peer chats
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
/// copies.
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
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)
let result = conversation.insert(message)
guard result.inserted else { return false }
registerMessageID(message.id, in: id)
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
return true
}
/// 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(let trimmedMessageIDs):
registerMessageID(message.id, in: id)
unregisterMessageIDs(trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
case .updated:
changes.send(.updated(id, messageID: message.id))
}
}
/// 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
}
/// Applies a delivery status to EVERY conversation containing
/// `messageID` (ID-only delivery receipts don't know conversations;
/// mirrored private copies live in two direct chats). Returns `false`
/// when the message is unknown or no copy changed (equal status or
/// downgrade read beats delivered beats sent).
///
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
/// instance are mutated by the first conversation's apply. The skipped
/// conversations still hold the changed message, so they get an explicit
/// republish and `.statusChanged` event - otherwise a view observing the
/// mirrored conversation would render stale status. Distinct copies whose
/// update was genuinely rejected (downgrade) are left untouched, guarded
/// by status equality.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false }
var applied = false
var skipped: [ConversationID] = []
for id in ids {
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
applied = true
} else {
skipped.append(id)
}
}
guard applied else { return false }
for id in skipped {
guard let conversation = conversationsByID[id],
conversation.message(withID: messageID)?.deliveryStatus == status,
conversation.republishMessage(withID: messageID) else { continue }
changes.send(.statusChanged(id, messageID: messageID, status))
}
return true
}
/// Current delivery status of `messageID` in whichever conversation
/// holds it (mirrored copies share status see `setDeliveryStatus`).
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
for id in ids {
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
return status
}
}
return nil
}
/// Every conversation currently containing `messageID` (empty when the
/// message is unknown).
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
conversationIDsByMessageID[messageID] ?? []
}
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
}
/// Switches the active public channel. While no private chat is open
/// the selection follows the channel.
func setActiveChannel(_ channelID: ChannelID) {
if activeChannel != channelID {
activeChannel = channelID
}
refreshDerivedSelection()
}
/// Opens a private chat (`nil` closes it, returning the selection to the
/// active public channel's conversation).
func setSelectedPrivatePeer(_ peerID: PeerID?) {
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
}
refreshDerivedSelection()
}
private func refreshDerivedSelection() {
if let peerID = selectedPrivatePeerID {
select(.directPeer(peerID))
} else {
select(ConversationID(channelID: activeChannel))
}
}
/// Moves all messages from `source` into `destination` (the
/// ephemeralstable peer-ID handoff): dedups by message ID, preserves
/// timestamp order, carries unread state over, and hands off the
/// 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 {
let result = destinationConversation.insert(message)
guard result.inserted else { continue }
registerMessageID(message.id, in: destination)
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
}
for messageID in sourceConversation.messageIDs {
unregisterMessageID(messageID, from: source)
}
let wasUnread = unreadConversations.contains(source)
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
// 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))
}
/// Removes a single message by ID from a conversation. Returns the
/// removed message, or `nil` (emitting nothing) when the conversation or
/// message is unknown.
@discardableResult
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
guard let conversation = conversationsByID[id],
let removed = conversation.remove(messageID: messageID) else {
return nil
}
unregisterMessageID(messageID, from: id)
changes.send(.messageRemoved(id, messageID: messageID))
return removed
}
/// Removes every message matching `predicate` from a conversation,
/// emitting one `.messageRemoved` per removed message after the
/// conversation is consistent. No-op for unknown conversations.
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
guard let conversation = conversationsByID[id] else { return }
let removedIDs = conversation.removeAll(where: predicate)
unregisterMessageIDs(removedIDs, from: id)
for messageID in removedIDs {
changes.send(.messageRemoved(id, messageID: messageID))
}
}
/// Empties a conversation's timeline but keeps the conversation (and
/// its unread/selection state) alive.
func clear(_ id: ConversationID) {
guard let conversation = conversationsByID[id] else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
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 let conversation = conversationsByID.removeValue(forKey: id) else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversationIDs.removeAll { $0 == id }
unreadConversations.remove(id)
if selectedConversationID == id {
selectedConversationID = nil
}
changes.send(.removed(id))
}
func clearAll() {
let removedIDs = conversationIDs
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
conversationsByID.removeAll()
conversationIDs.removeAll()
unreadConversations.removeAll()
conversationIDsByMessageID.removeAll()
if selectedConversationID != nil {
selectedConversationID = nil
}
for id in removedIDs {
changes.send(.removed(id))
}
}
// MARK: Internals
private func registerMessageID(_ messageID: String, in id: ConversationID) {
conversationIDsByMessageID[messageID, default: []].insert(id)
}
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
guard var ids = conversationIDsByMessageID[messageID] else { return }
ids.remove(id)
if ids.isEmpty {
conversationIDsByMessageID.removeValue(forKey: messageID)
} else {
conversationIDsByMessageID[messageID] = ids
}
}
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
for messageID in messageIDs {
unregisterMessageID(messageID, from: id)
}
}
private static func cap(for id: ConversationID) -> Int {
switch id {
case .mesh:
return TransportConfig.meshTimelineCap
case .geohash:
return TransportConfig.geoTimelineCap
case .direct:
return TransportConfig.privateChatCap
}
}
}
// MARK: - Direct-conversation keying + derived views
extension ConversationID {
/// Direct-conversation ID keyed by the *raw* routing peer ID.
///
/// Direct conversations are deliberately keyed per `PeerID`, not per
/// resolved identity: the private-chat coordinators mirror messages into
/// both the ephemeral and stable peer's conversations
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
/// explicitly, so a raw lookup by whichever peer ID is selected always
/// finds the right timeline without an identity-resolution layer.
static func directPeer(_ peerID: PeerID) -> ConversationID {
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
}
}
extension ConversationStore {
/// All direct conversations' messages keyed by routing peer ID the
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
/// Values are the conversations' backing arrays (COW), so building this
/// is O(#conversations), not O(#messages).
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
messagesByPeerID.reserveCapacity(conversationsByID.count)
for (id, conversation) in conversationsByID {
guard case .direct(let handle) = id else { continue }
messagesByPeerID[handle.routingPeerID] = conversation.messages
}
return messagesByPeerID
}
/// Unread direct conversations as routing peer IDs the shape
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
var peerIDs = Set<PeerID>()
for id in unreadConversations {
guard case .direct(let handle) = id else { continue }
peerIDs.insert(handle.routingPeerID)
}
return peerIDs
}
/// `true` when any direct conversation contains a message with `messageID`
/// (O(1) via the store-level message-ID conversation map).
func directConversationsContainMessage(withID messageID: String) -> Bool {
conversationIDs(forMessageID: messageID).contains { id in
if case .direct = id { return true }
return false
}
}
/// Message IDs across all direct conversations (read-receipt pruning
/// keeps only receipts whose messages still exist).
func directMessageIDs() -> Set<String> {
var messageIDs = Set<String>()
for (id, conversation) in conversationsByID {
guard case .direct = id else { continue }
messageIDs.formUnion(conversation.messageIDs)
}
return messageIDs
}
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
}
// MARK: - Public timeline derived views
extension ConversationStore {
/// Removes a message by ID from whichever public (mesh/geohash)
/// conversation contains it. Returns the removed message, if any.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for id in conversationIDs(forMessageID: messageID) {
switch id {
case .mesh, .geohash:
return removeMessage(withID: messageID, from: id)
case .direct:
continue
}
}
return nil
}
}
+5 -5
View File
@@ -15,19 +15,19 @@ final class ConversationUIModel: ObservableObject {
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversationStore: ConversationStore
conversations: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore
self.activeChannel = conversationStore.activeChannel
self.conversations = conversations
self.activeChannel = conversations.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
@@ -151,7 +151,7 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversationStore.$activeChannel
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
+4 -4
View File
@@ -37,7 +37,7 @@ final class PeerListModel: ObservableObject {
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
@@ -45,13 +45,13 @@ final class PeerListModel: ObservableObject {
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
@@ -122,7 +122,7 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
conversationStore.$unreadConversations
conversations.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
+67 -43
View File
@@ -2,69 +2,93 @@ import BitFoundation
import Combine
import Foundation
/// Feature model for private (direct) conversations.
///
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
/// returns the peer's conversation backing array (no mirror dictionary), and
/// the store's typed `changes` subject drives invalidation a change in the
/// SELECTED peer's conversation republishes this model, while appends to
/// other private chats only surface through the unread set. Direct
/// conversations are keyed by raw routing peer ID; the coordinators'
/// ephemeral/stable mirroring guarantees the selected peer's key always
/// holds the full timeline (see `ConversationID.directPeer`).
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.conversationStore = conversationStore
init(conversations: ConversationStore) {
self.conversations = conversations
self.selectedPeerID = conversations.selectedPrivatePeerID
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
bind()
refreshMessages()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? []
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
conversations.$selectedPrivatePeerID
.dropFirst()
.sink { [weak self] peerID in
self?.selectedPeerID = peerID
self?.refreshMessages()
guard let self, self.selectedPeerID != peerID else { return }
self.selectedPeerID = peerID
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
conversations.changes
.sink { [weak self] change in
self?.apply(change)
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
selectedPeerID = conversationStore.selectedPrivatePeerID
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
}
private func refreshMessages() {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
var peerIDs = Set(nextMessagesByPeerID.keys)
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
peerIDs.insert(selectedPeerID)
}
private func apply(_ change: ConversationChange) {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id):
republishIfSelected(id)
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
nextMessagesByPeerID[peerID] = []
}
case .unreadChanged(let id, _):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
guard messagesByPeerID != nextMessagesByPeerID else { return }
messagesByPeerID = nextMessagesByPeerID
case .removed(let id):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
republishIfSelected(id)
case .migrated(let source, let destination):
guard isDirect(source) || isDirect(destination) else { return }
refreshUnreadPeerIDs()
republishIfSelected(source)
republishIfSelected(destination)
}
}
private func republishIfSelected(_ id: ConversationID) {
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
objectWillChange.send()
}
private func refreshUnreadPeerIDs() {
let next = conversations.unreadDirectRoutingPeerIDs()
guard unreadPeerIDs != next else { return }
unreadPeerIDs = next
}
private func isDirect(_ id: ConversationID) -> Bool {
if case .direct = id { return true }
return false
}
}
@@ -94,22 +118,22 @@ final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID
let initialPeerID = conversations.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
@@ -154,7 +178,7 @@ final class PrivateConversationModel: ObservableObject {
}
private func bind() {
conversationStore.$selectedPrivatePeerID
conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
@@ -198,7 +222,7 @@ final class PrivateConversationModel: ObservableObject {
}
private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID
selectedPeerID = conversations.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
+52 -18
View File
@@ -2,42 +2,76 @@ import BitFoundation
import Combine
import SwiftUI
/// Feature model for the active public (mesh/geohash) timeline.
///
/// Observes ONE `Conversation` object in the single-writer
/// `ConversationStore` the active channel's so appends to background
/// conversations (other geohashes, private chats) never invalidate it.
/// `messages` reads the observed conversation's backing array directly;
/// there is no mirror copy.
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
@Published private(set) var messages: [BitchatMessage] = []
private let conversationStore: ConversationStore
/// The active public conversation's timeline.
var messages: [BitchatMessage] { activeConversation.messages }
private let conversations: ConversationStore
private var activeConversation: Conversation
private var activeConversationCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.activeChannel = conversationStore.activeChannel
self.conversationStore = conversationStore
init(conversations: ConversationStore) {
let channel = conversations.activeChannel
self.conversations = conversations
self.activeChannel = channel
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
observeActiveConversation()
bind()
refreshMessages()
}
private func bind() {
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
conversations.$activeChannel
.dropFirst()
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshMessages()
guard let self else { return }
self.activeChannel = channel
self.retargetActiveConversation(to: channel)
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
// The store replaces a conversation's object when it is removed
// (panic clear); retarget to the fresh instance so the observation
// never goes stale.
conversations.changes
.sink { [weak self] change in
guard let self,
case .removed(let id) = change,
id == self.activeConversation.id else { return }
self.retargetActiveConversation(to: self.activeChannel)
}
.store(in: &cancellables)
}
private func refreshMessages() {
let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
guard messages != nextMessages else { return }
messages = nextMessages
private func retargetActiveConversation(to channel: ChannelID) {
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
guard conversation !== activeConversation else {
// Same object (e.g. re-selected channel): keep the existing
// observation, but `messages` may still differ from what views
// last rendered, so republish.
objectWillChange.send()
return
}
objectWillChange.send()
activeConversation = conversation
observeActiveConversation()
}
private func observeActiveConversation() {
activeConversationCancellable = activeConversation.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
}
+3 -2
View File
@@ -31,7 +31,6 @@ protocol CommandContextProvider: AnyObject {
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
@@ -43,6 +42,8 @@ protocol CommandContextProvider: AnyObject {
func startPrivateChat(with peerID: PeerID)
func sendPrivateMessage(_ content: String, to peerID: PeerID)
func clearCurrentPublicTimeline()
/// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String)
// MARK: - System Messages
@@ -160,7 +161,7 @@ final class CommandProcessor {
private func handleClear() -> CommandResult {
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.privateChats[peerID]?.removeAll()
contextProvider?.clearPrivateChat(peerID)
} else {
contextProvider?.clearCurrentPublicTimeline()
}
+92 -106
View File
@@ -11,11 +11,13 @@ import BitFoundation
import Foundation
import SwiftUI
/// Manages all private chat functionality
/// Manages private chat session policy (selection, read receipts,
/// consolidation). Message storage lives in the single-writer
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
/// `privateChats` / `unreadMessages` properties below are read-only views
/// derived from it.
final class PrivateChatManager: ObservableObject {
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -25,13 +27,34 @@ final class PrivateChatManager: ObservableObject {
weak var messageRouter: MessageRouter?
// Peer service for looking up peer info during consolidation
weak var unifiedPeerService: UnifiedPeerService?
/// Single source of truth for message state; injected by the
/// bootstrapper (`wireServiceGraph`).
var conversationStore: ConversationStore?
init(meshService: Transport? = nil) {
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
self.meshService = meshService
self.conversationStore = conversationStore
}
// Cap for messages stored per private chat
private let privateChatCap = TransportConfig.privateChatCap
// MARK: - Derived message state (read-only compat views)
/// All private chats keyed by routing peer ID, derived from the store.
/// Mutations go through the store's intent API only.
@MainActor
var privateChats: [PeerID: [BitchatMessage]] {
conversationStore?.directMessagesByRoutingPeerID() ?? [:]
}
/// Unread chats, derived from the store's unread state.
@MainActor
var unreadMessages: Set<PeerID> {
conversationStore?.unreadDirectRoutingPeerIDs() ?? []
}
@MainActor
private func messages(for peerID: PeerID) -> [BitchatMessage] {
conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
// MARK: - Message Consolidation
@@ -44,57 +67,51 @@ final class PrivateChatManager: ObservableObject {
/// - Returns: True if any unread messages were found during consolidation
@MainActor
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
guard let meshService = meshService else { return false }
guard let meshService = meshService, let store = conversationStore else { return false }
var hasUnreadMessages = false
// 1. Consolidate from stable Noise key (64-char hex)
if let peer = unifiedPeerService?.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let nostrMessages = messages(for: noiseKeyHex)
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
for message in nostrMessages {
if !existingMessageIds.contains(message.id) {
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
// Store append dedups by message ID (skips ones the
// target chat already has).
guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue }
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
}
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
}
}
}
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
if hasUnreadMessages {
unreadMessages.insert(peerID)
} else if unreadMessages.contains(noiseKeyHex) {
unreadMessages.remove(noiseKeyHex)
store.markUnread(.directPeer(peerID))
} else {
store.markRead(.directPeer(noiseKeyHex))
}
privateChats.removeValue(forKey: noiseKeyHex)
store.removeConversation(.directPeer(noiseKeyHex))
}
}
@@ -112,52 +129,43 @@ final class PrivateChatManager: ObservableObject {
}
if !tempPeerIDsToConsolidate.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
var consolidatedCount = 0
var hadUnreadTemp = false
let unreadPeerIDs = unreadMessages
for tempPeerID in tempPeerIDsToConsolidate {
if unreadMessages.contains(tempPeerID) {
if unreadPeerIDs.contains(tempPeerID) {
hadUnreadTemp = true
}
if let tempMessages = privateChats[tempPeerID] {
for message in tempMessages {
if !existingMessageIds.contains(message.id) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
consolidatedCount += 1
}
for message in messages(for: tempPeerID) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
if store.append(updatedMessage, to: .directPeer(peerID)) {
consolidatedCount += 1
}
privateChats.removeValue(forKey: tempPeerID)
unreadMessages.remove(tempPeerID)
}
store.removeConversation(.directPeer(tempPeerID))
}
if hadUnreadTemp {
unreadMessages.insert(peerID)
store.markUnread(.directPeer(peerID))
hasUnreadMessages = true
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
}
if consolidatedCount > 0 {
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
}
}
@@ -168,9 +176,7 @@ final class PrivateChatManager: ObservableObject {
/// Syncs the read receipt tracking between manager and view model for sent messages
@MainActor
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
guard let messages = privateChats[peerID] else { return }
for message in messages {
for message in messages(for: peerID) {
if message.sender == nickname {
if let status = message.deliveryStatus {
switch status {
@@ -186,6 +192,7 @@ final class PrivateChatManager: ObservableObject {
}
/// Start a private chat with a peer
@MainActor
func startChat(with peerID: PeerID) {
selectedPeer = peerID
@@ -198,9 +205,7 @@ final class PrivateChatManager: ObservableObject {
markAsRead(from: peerID)
// Initialize chat if needed
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
conversationStore?.conversation(for: .directPeer(peerID))
}
/// End the current private chat
@@ -209,40 +214,21 @@ final class PrivateChatManager: ObservableObject {
selectedPeerFingerprint = nil
}
/// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: PeerID) {
guard let arr = privateChats[peerID] else { return }
if arr.count <= 1 {
return
}
var indexByID: [String: Int] = [:]
indexByID.reserveCapacity(arr.count)
var deduped: [BitchatMessage] = []
deduped.reserveCapacity(arr.count)
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if let existing = indexByID[msg.id] {
deduped[existing] = msg
} else {
indexByID[msg.id] = deduped.count
deduped.append(msg)
}
}
privateChats[peerID] = deduped
}
/// No-op since the `ConversationStore` cutover: the store maintains
/// chronological order and dedups by message ID on every insert, so the
/// per-append re-sort/dedup sweep this performed is no longer needed.
/// Kept only for API compatibility until step 5 removes the callers.
func sanitizeChat(for peerID: PeerID) {}
/// Mark messages from a peer as read
@MainActor
func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID)
conversationStore?.markRead(.directPeer(peerID))
// Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
for message in messages(for: peerID) {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
}
-3
View File
@@ -51,9 +51,6 @@ enum TransportConfig {
static let nostrInboundEventLogInterval: Int = 100
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
+36 -242
View File
@@ -12,9 +12,19 @@ import Foundation
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
@MainActor
protocol ChatDeliveryContext: AnyObject {
var messages: [BitchatMessage] { get set }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var isStartupPhase: Bool { get }
/// Applies a delivery status to every copy of the message across
/// conversations (`ConversationStore` intent, ID-only: the store's
/// message-ID conversation map resolves which conversations hold the
/// message, including mirrored ephemeral/stable private copies). The
/// no-downgrade rule is enforced in the store. Returns `false` when the
/// message is unknown or no copy changed.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
/// Current delivery status of the message in whichever conversation holds it.
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
/// Message IDs across all direct conversations (read-receipt pruning).
func privateMessageIDs() -> Set<String>
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
/// Returns the number of receipts removed. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
@@ -26,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject {
}
extension ChatViewModel: ChatDeliveryContext {
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
conversations.setDeliveryStatus(status, forMessageID: messageID)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
conversations.deliveryStatus(forMessageID: messageID)
}
func privateMessageIDs() -> Set<String> {
conversations.directMessageIDs()
}
func notifyUIChanged() {
objectWillChange.send()
}
@@ -35,14 +58,12 @@ extension ChatViewModel: ChatDeliveryContext {
}
}
/// Thin mapper from delivery events (read receipts, transport delivery
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
/// retention cleanup. The store's message-ID conversation map replaces the
/// positional `messageLocationIndex` this coordinator used to maintain.
final class ChatDeliveryCoordinator {
private unowned let context: any ChatDeliveryContext
private var messageLocationIndex: [String: Set<MessageLocation>] = [:]
private var indexedPublicMessageCount = 0
private var indexedPublicTailMessageID: String?
private var indexedPrivateMessageCounts: [PeerID: Int] = [:]
private var indexedPrivateTailMessageIDs: [PeerID: String] = [:]
private var hasBuiltMessageLocationIndex = false
init(context: any ChatDeliveryContext) {
self.context = context
@@ -50,15 +71,9 @@ final class ChatDeliveryCoordinator {
@MainActor
func cleanupOldReadReceipts() {
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
return
}
let validMessageIDs = Set(
context.privateChats.values.flatMap { messages in
messages.map(\.id)
}
)
guard !context.isStartupPhase else { return }
let validMessageIDs = context.privateMessageIDs()
guard !validMessageIDs.isEmpty else { return }
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
if removedCount > 0 {
@@ -81,9 +96,7 @@ final class ChatDeliveryCoordinator {
@MainActor
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
withValidLocations(for: messageID) { locations in
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
}
context.deliveryStatus(forMessageID: messageID)
}
@MainActor
@@ -97,229 +110,10 @@ final class ChatDeliveryCoordinator {
break
}
var didUpdateStatus = false
var didUpdatePrivateStatus = false
let locations = withValidLocations(for: messageID) { $0 }
guard !locations.isEmpty else { return false }
for location in locations {
guard case .publicTimeline(let index) = location,
index < context.messages.count,
context.messages[index].id == messageID else {
continue
}
let currentStatus = context.messages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
context.messages[index].deliveryStatus = status
didUpdateStatus = true
}
}
var privateChats = context.privateChats
for location in locations {
guard case .privateChat(let peerID, let index) = location,
let chatMessages = privateChats[peerID],
index < chatMessages.count,
chatMessages[index].id == messageID else {
continue
}
let currentStatus = chatMessages[index].deliveryStatus
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
chatMessages[index].deliveryStatus = status
privateChats[peerID] = chatMessages
didUpdateStatus = true
didUpdatePrivateStatus = true
}
if didUpdatePrivateStatus {
context.privateChats = privateChats
}
if didUpdateStatus {
context.notifyUIChanged()
}
return didUpdateStatus
}
}
private extension ChatDeliveryCoordinator {
enum MessageLocation: Hashable {
case publicTimeline(index: Int)
case privateChat(peerID: PeerID, index: Int)
}
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
guard let currentStatus else { return false }
if currentStatus == newStatus { return true }
switch (currentStatus, newStatus) {
case (.read, .delivered), (.read, .sent):
return true
default:
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
return false
}
}
@MainActor
func withValidLocations<T>(
for messageID: String,
_ body: (Set<MessageLocation>) -> T
) -> T {
let didRebuildIndex = refreshMessageLocationIndexForGrowth()
if let locations = messageLocationIndex[messageID],
locations.allSatisfy({ isLocation($0, validFor: messageID) }) {
return body(locations)
}
guard !didRebuildIndex else {
return body(messageLocationIndex[messageID] ?? [])
}
if messageLocationIndex[messageID] == nil {
return body([])
}
rebuildMessageLocationIndex()
return body(messageLocationIndex[messageID] ?? [])
}
@MainActor
func deliveryStatus(at location: MessageLocation) -> DeliveryStatus? {
switch location {
case .publicTimeline(let index):
guard index < context.messages.count else { return nil }
return context.messages[index].deliveryStatus
case .privateChat(let peerID, let index):
guard let messages = context.privateChats[peerID],
index < messages.count else {
return nil
}
return messages[index].deliveryStatus
}
}
@MainActor
func isLocation(_ location: MessageLocation, validFor messageID: String) -> Bool {
switch location {
case .publicTimeline(let index):
return index < context.messages.count
&& context.messages[index].id == messageID
case .privateChat(let peerID, let index):
guard let messages = context.privateChats[peerID],
index < messages.count else {
return false
}
return messages[index].id == messageID
}
}
@MainActor
@discardableResult
func refreshMessageLocationIndexForGrowth() -> Bool {
guard hasBuiltMessageLocationIndex else {
rebuildMessageLocationIndex()
return true
}
if context.messages.count < indexedPublicMessageCount {
rebuildMessageLocationIndex()
return true
}
if context.messages.count == indexedPublicMessageCount,
context.messages.last?.id != indexedPublicTailMessageID {
rebuildMessageLocationIndex()
return true
}
if context.messages.count > indexedPublicMessageCount {
// Growth is only a pure append if the previously indexed tail kept
// its position; a middle insertion (out-of-order timestamp arrival)
// shifts it and invalidates every indexed location after the
// insertion point.
if indexedPublicMessageCount > 0,
context.messages[indexedPublicMessageCount - 1].id != indexedPublicTailMessageID {
rebuildMessageLocationIndex()
return true
}
for index in indexedPublicMessageCount..<context.messages.count {
add(.publicTimeline(index: index), for: context.messages[index].id)
}
indexedPublicMessageCount = context.messages.count
indexedPublicTailMessageID = context.messages.last?.id
}
let currentPeerIDs = Set(context.privateChats.keys)
if !Set(indexedPrivateMessageCounts.keys).isSubset(of: currentPeerIDs) {
rebuildMessageLocationIndex()
return true
}
for (peerID, messages) in context.privateChats {
let indexedCount = indexedPrivateMessageCounts[peerID] ?? 0
if messages.count < indexedCount {
rebuildMessageLocationIndex()
return true
}
if messages.count == indexedCount,
messages.last?.id != indexedPrivateTailMessageIDs[peerID] {
rebuildMessageLocationIndex()
return true
}
guard messages.count > indexedCount else { continue }
// Same append-only check as the public timeline above.
if indexedCount > 0,
messages[indexedCount - 1].id != indexedPrivateTailMessageIDs[peerID] {
rebuildMessageLocationIndex()
return true
}
for index in indexedCount..<messages.count {
add(.privateChat(peerID: peerID, index: index), for: messages[index].id)
}
indexedPrivateMessageCounts[peerID] = messages.count
if let tailID = messages.last?.id {
indexedPrivateTailMessageIDs[peerID] = tailID
} else {
indexedPrivateTailMessageIDs.removeValue(forKey: peerID)
}
}
return false
}
@MainActor
func rebuildMessageLocationIndex() {
messageLocationIndex.removeAll(keepingCapacity: true)
for (index, message) in context.messages.enumerated() {
add(.publicTimeline(index: index), for: message.id)
}
indexedPublicMessageCount = context.messages.count
indexedPublicTailMessageID = context.messages.last?.id
indexedPrivateMessageCounts.removeAll(keepingCapacity: true)
indexedPrivateTailMessageIDs.removeAll(keepingCapacity: true)
for (peerID, messages) in context.privateChats {
for (index, message) in messages.enumerated() {
add(.privateChat(peerID: peerID, index: index), for: message.id)
}
indexedPrivateMessageCounts[peerID] = messages.count
if let tailID = messages.last?.id {
indexedPrivateTailMessageIDs[peerID] = tailID
}
}
hasBuiltMessageLocationIndex = true
}
func add(_ location: MessageLocation, for messageID: String) {
messageLocationIndex[messageID, default: []].insert(location)
context.notifyUIChanged()
return true
}
}
@@ -13,9 +13,16 @@ import Foundation
protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state
var messages: [BitchatMessage] { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Clears the peer's unread flag (store unread state only).
func markPrivateChatRead(_ peerID: PeerID)
var sentReadReceipts: Set<String> { get }
var nickname: String { get }
var myPeerID: PeerID { get }
@@ -33,7 +40,6 @@ protocol ChatLifecycleContext: AnyObject {
/// Schedules main-actor work after a UI-timing delay. Injected so tests
/// can run the work synchronously instead of polling wall-clock queues.
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
func synchronizePrivateConversationStore()
func addSystemMessage(_ content: String)
// MARK: Peers & sessions
@@ -69,11 +75,11 @@ protocol ChatLifecycleContext: AnyObject {
}
extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateChats`, `unreadPrivateMessages`,
// `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`,
// `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
// the routing/ack members, `isTeleported`,
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
@@ -178,13 +184,12 @@ final class ChatLifecycleCoordinator {
func markPrivateMessagesAsRead(from peerID: PeerID) {
context.markChatAsRead(from: peerID)
context.synchronizePrivateConversationStore()
if peerID.isGeoDM,
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let messages = context.privateChats[peerID] ?? []
let messages = context.privateMessages(for: peerID)
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !context.sentReadReceipts.contains(message.id) else { continue }
@@ -215,7 +220,7 @@ final class ChatLifecycleCoordinator {
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
context.unreadPrivateMessages.remove(noiseKeyHex)
context.markPrivateChatRead(noiseKeyHex)
}
}
@@ -250,14 +255,12 @@ final class ChatLifecycleCoordinator {
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
if let ephemeralMessages = context.privateChats[peerID] {
combined.append(contentsOf: ephemeralMessages)
}
combined.append(contentsOf: context.privateMessages(for: peerID))
if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] {
combined.append(contentsOf: stableMessages)
if noiseKeyHex != peerID {
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
}
}
@@ -312,12 +315,7 @@ private extension ChatLifecycleCoordinator {
senderPeerID: context.myPeerID
)
var chats = context.privateChats
if chats[peerID] == nil {
chats[peerID] = []
}
chats[peerID]?.append(notice)
context.privateChats = chats
context.appendPrivateMessage(notice, to: peerID)
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
@@ -25,10 +25,13 @@ protocol ChatMediaTransferContext: AnyObject {
func currentPublicSender() -> (name: String, peerID: PeerID)
// MARK: Message state
var privateChats: [PeerID: [BitchatMessage]] { get set }
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
func refreshVisibleMessages(from channel: ChannelID?)
func trimMessagesIfNeeded()
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Appends a public message via the single-writer store intent
/// (immediate: outgoing media placeholders must render without batching).
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func removeMessage(withID messageID: String, cleanupFile: Bool)
func addSystemMessage(_ content: String)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
@@ -48,9 +51,8 @@ protocol ChatMediaTransferContext: AnyObject {
extension ChatViewModel: ChatMediaTransferContext {
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
// `currentPublicSender()`, `privateChats`,
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
// `trimMessagesIfNeeded()`, `removeMessage(withID:cleanupFile:)`,
// `currentPublicSender()`,
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
// `addSystemMessage(_:)`, `notifyUIChanged()`,
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
// and `recordContentKey(_:timestamp:)` are shared requirements with the
@@ -228,10 +230,7 @@ final class ChatMediaTransferCoordinator {
senderPeerID: context.myPeerID,
deliveryStatus: .sending
)
var chats = context.privateChats
chats[peerID, default: []].append(message)
context.privateChats = chats
context.trimMessagesIfNeeded()
context.appendPrivateMessage(message, to: peerID)
} else {
let (displayName, senderPeerID) = context.currentPublicSender()
message = BitchatMessage(
@@ -245,9 +244,7 @@ final class ChatMediaTransferCoordinator {
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
context.appendTimelineMessage(message, to: context.activeChannel)
context.refreshVisibleMessages(from: context.activeChannel)
context.trimMessagesIfNeeded()
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
}
let key = context.normalizedContentKey(message.content)
@@ -25,9 +25,10 @@ protocol ChatOutgoingContext: AnyObject {
// MARK: Public timeline (local echo)
func parseMentions(from content: String) -> [String]
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
func refreshVisibleMessages(from channel: ChannelID?)
func trimMessagesIfNeeded()
/// Appends a public message via the single-writer store intent
/// (immediate: the local echo must render without batching).
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func addSystemMessage(_ content: String)
// MARK: Content dedup
@@ -50,8 +51,7 @@ extension ChatViewModel: ChatOutgoingContext {
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
// `trimMessagesIfNeeded()`, `addSystemMessage(_:)`,
// `appendPublicMessage(_:to:)`, `addSystemMessage(_:)`,
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
@@ -169,12 +169,10 @@ private extension ChatOutgoingCoordinator {
}
func appendLocalEcho(_ message: BitchatMessage) {
context.appendTimelineMessage(message, to: context.activeChannel)
context.refreshVisibleMessages(from: context.activeChannel)
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
context.trimMessagesIfNeeded()
}
func routePublicMessage(
@@ -17,8 +17,16 @@ import Foundation
@MainActor
protocol ChatPeerIdentityContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (dedup by ID, order preserved, unread carried, old chat removed).
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
var selectedPrivateChatPeer: PeerID? { get set }
var selectedPrivateChatFingerprint: String? { get set }
var nickname: String { get }
@@ -39,7 +47,6 @@ protocol ChatPeerIdentityContext: AnyObject {
func syncReadReceiptsForSentMessages(for peerID: PeerID)
/// Re-targets the private chat session in the chat manager (no store-sync side effects).
func beginPrivateChatSession(with peerID: PeerID)
func synchronizePrivateConversationStore()
func synchronizeConversationSelectionStore()
func markPrivateMessagesAsRead(from peerID: PeerID)
@@ -225,7 +232,7 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func openMostRelevantPrivateChat() {
let unreadSorted = context.unreadPrivateMessages
.map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
.map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
@@ -305,9 +312,7 @@ final class ChatPeerIdentityCoordinator {
context.selectedPrivateChatPeer = currentPeerID
}
var unread = context.unreadPrivateMessages
unread.remove(currentPeerID)
context.unreadPrivateMessages = unread
context.markPrivateChatRead(currentPeerID)
}
@MainActor
@@ -367,7 +372,6 @@ final class ChatPeerIdentityCoordinator {
context.selectedPrivateChatFingerprint = nil
}
context.beginPrivateChatSession(with: peerID)
context.synchronizePrivateConversationStore()
context.synchronizeConversationSelectionStore()
context.markPrivateMessagesAsRead(from: peerID)
}
@@ -553,37 +557,16 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
if let oldMessages = context.privateChats[oldPeerID] {
var chats = context.privateChats
chats[newPeerID, default: []].append(contentsOf: oldMessages)
chats[newPeerID]?.sort { $0.timestamp < $1.timestamp }
var seenMessageIDs = Set<String>()
chats[newPeerID] = chats[newPeerID]?.filter { message in
if seenMessageIDs.contains(message.id) {
return false
}
seenMessageIDs.insert(message.id)
return true
}
chats.removeValue(forKey: oldPeerID)
context.privateChats = chats
}
var unread = context.unreadPrivateMessages
if unread.contains(oldPeerID) {
unread.remove(oldPeerID)
unread.insert(newPeerID)
context.unreadPrivateMessages = unread
}
// The store migration dedups by message ID, preserves timestamp
// order, carries the unread flag, and removes the old chat.
context.migratePrivateChat(from: oldPeerID, to: newPeerID)
}
@MainActor
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
if context.selectedPrivateChatPeer == oldPeerID {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if context.privateChats[oldPeerID] != nil {
} else if !context.privateMessages(for: oldPeerID).isEmpty {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
}
@@ -633,7 +616,7 @@ private extension ChatPeerIdentityCoordinator {
}
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
fallbackNickname: fallbackNickname,
@@ -662,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,8 +13,12 @@ import Foundation
protocol ChatPeerListContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
var unreadPrivateMessages: Set<PeerID> { get set }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
var hasTrackedPrivateChatSelection: Bool { get }
func updatePrivateChatPeerIfNeeded()
func cleanupOldReadReceipts()
@@ -35,7 +39,7 @@ protocol ChatPeerListContext: AnyObject {
}
extension ChatViewModel: ChatPeerListContext {
// `isConnected`, `privateChats`, `unreadPrivateMessages`,
// `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
@@ -146,16 +150,16 @@ private extension ChatPeerListCoordinator {
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty {
if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty {
continue
}
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty {
continue
}
idsToRemove.append(staleID)
context.unreadPrivateMessages.remove(staleID)
context.markPrivateChatRead(staleID)
}
if !idsToRemove.isEmpty {
@@ -14,13 +14,42 @@ import Foundation
@MainActor
protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get set }
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
// MARK: Conversation store intents
// The sole mutation paths for private message state (single-writer
// `ConversationStore` ops; see docs/CONVERSATION-STORE-DESIGN.md).
/// Appends a private message in timestamp order; returns `false` on
/// duplicate message ID.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Replace-or-append a private message by ID, keeping its position.
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID)
/// Applies a delivery status by message ID; returns `false` when the
/// message is unknown or the update would downgrade the status.
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
func markPrivateChatUnread(_ peerID: PeerID)
func markPrivateChatRead(_ peerID: PeerID)
/// Removes the peer's chat entirely, including unread state.
func removePrivateChat(_ peerID: PeerID)
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (dedup by ID, order preserved, unread carried, old chat removed).
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
/// `true` when any private chat contains a message with `messageID`.
func privateChatsContainMessage(withID messageID: String) -> Bool
/// `true` when `peerID`'s chat contains a message with `messageID`.
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
@@ -65,10 +94,9 @@ protocol ChatPrivateConversationContext: AnyObject {
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
// MARK: System messages & chat hygiene
// MARK: System messages
func addSystemMessage(_ content: String)
func addMeshOnlySystemMessage(_ content: String)
func sanitizeChat(for peerID: PeerID)
// MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any.
@@ -165,10 +193,6 @@ extension ChatViewModel: ChatPrivateConversationContext {
addSystemMessage(content, timestamp: Date())
}
func sanitizeChat(for peerID: PeerID) {
privateChatManager.sanitizeChat(for: peerID)
}
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
}
@@ -249,10 +273,7 @@ final class ChatPrivateConversationCoordinator {
deliveryStatus: .sending
)
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
context.privateChats[peerID]?.append(message)
context.appendPrivateMessage(message, to: peerID)
context.notifyUIChanged()
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
@@ -262,15 +283,15 @@ final class ChatPrivateConversationCoordinator {
recipientNickname: recipientNickname ?? "user",
messageID: messageID
)
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[idx].deliveryStatus = .sent
}
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
} else {
if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[index].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
)
}
),
forMessageID: messageID,
peerID: peerID
)
let name = recipientNickname ?? "user"
context.addSystemMessage(
String(
@@ -303,28 +324,28 @@ final class ChatPrivateConversationCoordinator {
deliveryStatus: .sending
)
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
context.privateChats[peerID]?.append(message)
context.appendPrivateMessage(message, to: peerID)
context.notifyUIChanged()
guard let recipientHex = context.nostrKeyMapping[peerID] else {
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
)
}
),
forMessageID: messageID,
peerID: peerID
)
return
}
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
)
}
),
forMessageID: messageID,
peerID: peerID
)
context.addSystemMessage(
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
)
@@ -334,11 +355,13 @@ final class ChatPrivateConversationCoordinator {
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
)
}
),
forMessageID: messageID,
peerID: peerID
)
return
}
@@ -352,15 +375,15 @@ final class ChatPrivateConversationCoordinator {
from: identity,
messageID: messageID
)
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
} catch {
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
)
}
),
forMessageID: messageID,
peerID: peerID
)
}
}
@@ -382,10 +405,7 @@ final class ChatPrivateConversationCoordinator {
return
}
if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) {
return
}
if context.privateChatsContainMessage(withID: messageId) { return }
let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage(
@@ -400,17 +420,14 @@ final class ChatPrivateConversationCoordinator {
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
if context.privateChats[convKey] == nil {
context.privateChats[convKey] = []
}
context.privateChats[convKey]?.append(message)
context.appendPrivateMessage(message, to: convKey)
let isViewing = context.selectedPrivateChatPeer == convKey
let wasReadBefore = context.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread {
context.unreadPrivateMessages.insert(convKey)
context.markPrivateChatUnread(convKey)
}
if isViewing {
@@ -427,10 +444,11 @@ final class ChatPrivateConversationCoordinator {
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[convKey]?[idx].deliveryStatus = .delivered(
to: context.displayNameForNostrPubkey(senderPubkey),
at: Date()
if context.privateChat(convKey, containsMessageWithID: messageID) {
context.setPrivateDeliveryStatus(
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
forMessageID: messageID,
peerID: convKey
)
context.notifyUIChanged()
SecureLogger.info(
@@ -445,10 +463,11 @@ final class ChatPrivateConversationCoordinator {
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[convKey]?[idx].deliveryStatus = .read(
by: context.displayNameForNostrPubkey(senderPubkey),
at: Date()
if context.privateChat(convKey, containsMessageWithID: messageID) {
context.setPrivateDeliveryStatus(
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
forMessageID: messageID,
peerID: convKey
)
context.notifyUIChanged()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
@@ -572,20 +591,12 @@ final class ChatPrivateConversationCoordinator {
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
let stableKeyHex = PeerID(hexData: peerNoiseKey)
let nostrMessages = context.privateMessages(for: stableKeyHex)
if stableKeyHex != peerID,
let nostrMessages = context.privateChats[stableKeyHex],
!nostrMessages.isEmpty {
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
let existingMessageIds = Set(context.privateChats[peerID]?.map { $0.id } ?? [])
for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) {
context.privateChats[peerID]?.append(nostrMessage)
}
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
context.privateChats.removeValue(forKey: stableKeyHex)
// Store migration dedups by ID, keeps timestamp order, and
// removes the stable-key chat.
context.migratePrivateChat(from: stableKeyHex, to: peerID)
SecureLogger.info(
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
@@ -612,33 +623,23 @@ final class ChatPrivateConversationCoordinator {
context.sendMeshReadReceipt(receipt, to: peerID)
context.markReadReceiptSent(message.id)
} else {
context.unreadPrivateMessages.insert(peerID)
context.markPrivateChatUnread(peerID)
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
}
context.notifyUIChanged()
}
/// O(1)-per-conversation dedup via the store's message-ID indexes
/// (replaces the full scan over every private chat).
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
if context.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
return true
}
for (_, messages) in context.privateChats where messages.contains(where: { $0.id == messageId }) {
return true
}
return false
context.privateChatsContainMessage(withID: messageId)
}
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
if context.privateChats[targetPeerID] == nil {
context.privateChats[targetPeerID] = []
}
if let idx = context.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
context.privateChats[targetPeerID]?[idx] = message
} else {
context.privateChats[targetPeerID]?.append(message)
}
context.sanitizeChat(for: targetPeerID)
// Store upsert replaces in place by message ID or inserts in
// timestamp order; the old per-append sanitize re-sort is obsolete.
context.upsertPrivateMessage(message, in: targetPeerID)
}
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
@@ -649,15 +650,7 @@ final class ChatPrivateConversationCoordinator {
return
}
if context.privateChats[ephemeralPeerID] == nil {
context.privateChats[ephemeralPeerID] = []
}
if let idx = context.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
context.privateChats[ephemeralPeerID]?[idx] = message
} else {
context.privateChats[ephemeralPeerID]?.append(message)
}
context.sanitizeChat(for: ephemeralPeerID)
context.upsertPrivateMessage(message, in: ephemeralPeerID)
}
func handleViewingThisChat(
@@ -666,10 +659,10 @@ final class ChatPrivateConversationCoordinator {
key: Data?,
senderPubkey: String
) {
context.unreadPrivateMessages.remove(targetPeerID)
context.markPrivateChatRead(targetPeerID)
if let key,
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
context.unreadPrivateMessages.remove(ephemeralPeerID)
context.markPrivateChatRead(ephemeralPeerID)
}
guard !context.sentReadReceipts.contains(message.id) else { return }
@@ -702,11 +695,11 @@ final class ChatPrivateConversationCoordinator {
) {
guard shouldMarkAsUnread else { return }
context.unreadPrivateMessages.insert(targetPeerID)
context.markPrivateChatUnread(targetPeerID)
if let key,
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
ephemeralPeerID != targetPeerID {
context.unreadPrivateMessages.insert(ephemeralPeerID)
context.markPrivateChatUnread(ephemeralPeerID)
}
if isRecentMessage {
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
@@ -777,9 +770,14 @@ final class ChatPrivateConversationCoordinator {
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
let currentFingerprint = context.getFingerprint(for: peerID)
if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
if context.privateMessages(for: peerID).isEmpty {
// Chats migrated wholesale go through the store's
// `migrateConversation` intent; partially-migrated chats keep
// their non-recent tail, so the recent messages are copied in
// via ordered append (dedup by ID) instead.
var partiallyMigratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [PeerID] = []
var didMigrate = false
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
@@ -790,10 +788,11 @@ final class ChatPrivateConversationCoordinator {
if let currentFp = currentFingerprint,
let oldFp = oldFingerprint,
currentFp == oldFp {
migratedMessages.append(contentsOf: recentMessages)
didMigrate = true
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
partiallyMigratedMessages.append(contentsOf: recentMessages)
SecureLogger.info(
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
category: .session
@@ -811,9 +810,11 @@ final class ChatPrivateConversationCoordinator {
}
if isRelevantChat {
migratedMessages.append(contentsOf: recentMessages)
didMigrate = true
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
partiallyMigratedMessages.append(contentsOf: recentMessages)
}
SecureLogger.warning(
@@ -826,21 +827,22 @@ final class ChatPrivateConversationCoordinator {
if !oldPeerIDsToRemove.isEmpty {
for oldID in oldPeerIDsToRemove {
context.privateChats.removeValue(forKey: oldID)
context.unreadPrivateMessages.remove(oldID)
// The old behavior dropped the unread flag of removed
// chats instead of transferring it; clear it before the
// migration so the store doesn't carry it over.
context.markPrivateChatRead(oldID)
context.migratePrivateChat(from: oldID, to: peerID)
context.clearStoredFingerprint(for: oldID)
}
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
}
if !migratedMessages.isEmpty {
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
context.privateChats[peerID]?.append(contentsOf: migratedMessages)
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
context.sanitizeChat(for: peerID)
for message in partiallyMigratedMessages {
context.appendPrivateMessage(message, to: peerID)
}
if didMigrate {
context.notifyUIChanged()
}
}
@@ -877,3 +879,11 @@ final class ChatPrivateConversationCoordinator {
return false
}
}
/// Default for conforming test contexts that model chats as a dictionary;
/// `ChatViewModel` overrides with a store-direct lookup.
extension ChatPrivateConversationContext {
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
}
@@ -19,8 +19,7 @@ import UIKit
/// pipeline.
@MainActor
protocol ChatPublicConversationContext: AnyObject {
// MARK: Channel & visible timeline state
var messages: [BitchatMessage] { get set }
// MARK: Channel state
var activeChannel: ChannelID { get }
var currentGeohash: String? { get }
var nickname: String { get }
@@ -31,29 +30,34 @@ protocol ChatPublicConversationContext: AnyObject {
func setPublicBatching(_ isBatching: Bool)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
func trimMessagesIfNeeded()
// MARK: Public timeline store
func timelineMessages(for channel: ChannelID) -> [BitchatMessage]
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
// MARK: Public conversation store (single-writer intents)
/// Appends a public message in timestamp order. Returns `false` when a
/// message with the same ID is already in that conversation.
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
/// Appends a geohash message if absent. Returns `true` when stored.
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
func removeTimelineMessage(withID id: String) -> BitchatMessage?
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool)
func clearTimeline(for channel: ChannelID)
func timelineGeohashKeys() -> [String]
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool
/// Removes a message by ID from whichever public conversation contains it.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage?
/// Removes every matching message from a geohash conversation (block purge).
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool)
/// Empties a public conversation's timeline (`/clear`).
func clearPublicConversation(_ conversationID: ConversationID)
/// Queues a system message for the next geohash channel visit.
func queueGeohashSystemMessage(_ content: String)
// MARK: Conversation stores
func setConversationActiveChannel(_ channel: ChannelID)
func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID)
func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID)
func synchronizePrivateConversationStore()
func synchronizeConversationSelectionStore()
// MARK: Private chats (block cleanup & message removal)
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
/// Removes the peer's chat entirely, including unread state
/// (single-writer store intent; no-op for unknown peers).
func removePrivateChat(_ peerID: PeerID)
/// Removes a message by ID from every private chat containing it,
/// dropping chats that become empty. Returns the removed message.
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
func cleanupLocalFile(forMessage message: BitchatMessage)
// MARK: Geohash participants & presence
@@ -79,7 +83,9 @@ protocol ChatPublicConversationContext: AnyObject {
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
func isMessageBlocked(_ message: BitchatMessage) -> Bool
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool
func enqueuePublicMessage(_ message: BitchatMessage)
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
/// flush, which commits it to `conversationID` in the store.
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
// MARK: Content dedup & formatting
@@ -95,55 +101,21 @@ protocol ChatPublicConversationContext: AnyObject {
}
extension ChatViewModel: ChatPublicConversationContext {
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
// `unreadPrivateMessages`, `nostrKeyMapping`,
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
// `deriveNostrIdentity(forGeohash:)`, and
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
// with `ChatDeliveryContext` / `ChatPrivateConversationContext` /
// `ChatNostrContext`; their witnesses already exist. The members below
// flatten nested service accesses into intent-named calls.
func timelineMessages(for channel: ChannelID) -> [BitchatMessage] {
timelineStore.messages(for: channel)
}
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
timelineStore.append(message, to: channel)
}
func removeTimelineMessage(withID id: String) -> BitchatMessage? {
timelineStore.removeMessage(withID: id)
}
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
timelineStore.removeMessages(in: geohash, where: predicate)
}
func clearTimeline(for channel: ChannelID) {
timelineStore.clear(channel: channel)
}
func timelineGeohashKeys() -> [String] {
timelineStore.geohashKeys()
}
func queueGeohashSystemMessage(_ content: String) {
timelineStore.queueGeohashSystemMessage(content)
}
func setConversationActiveChannel(_ channel: ChannelID) {
conversationStore.setActiveChannel(channel)
}
func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
conversationStore.replaceMessages(messages, for: channelID)
}
func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
conversationStore.replaceMessages(messages, for: conversationID)
}
// `deriveNostrIdentity(forGeohash:)`, the public conversation store
// intents (`appendPublicMessage(_:to:)`,
// `appendGeohashMessageIfAbsent(_:toGeohash:)`,
// `publicConversationContainsMessage(withID:in:)`,
// `removePublicMessage(withID:)`,
// `removePublicMessages(fromGeohash:where:)`,
// `clearPublicConversation(_:)`, and `queueGeohashSystemMessage(_:)`)
// are shared requirements with `ChatDeliveryContext` /
// `ChatPrivateConversationContext` / `ChatNostrContext` or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func visibleGeoPeople() -> [GeoPerson] {
participantTracker.getVisiblePeople()
@@ -169,8 +141,8 @@ extension ChatViewModel: ChatPublicConversationContext {
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
}
func enqueuePublicMessage(_ message: BitchatMessage) {
publicMessagePipeline.enqueue(message)
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
publicMessagePipeline.enqueue(message, to: conversationID)
}
func normalizedContentKey(_ content: String) -> String {
@@ -242,23 +214,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
return false
}
context.removeGeohashTimelineMessages(in: gh, where: predicate)
synchronizePublicConversationStore(forGeohash: gh)
if case .location = context.activeChannel {
context.messages.removeAll(where: predicate)
}
context.removePublicMessages(fromGeohash: gh, where: predicate)
}
let conversationPeerID = PeerID(nostr_: hex)
if context.privateChats[conversationPeerID] != nil {
var privateChats = context.privateChats
privateChats.removeValue(forKey: conversationPeerID)
context.privateChats = privateChats
var unread = context.unreadPrivateMessages
unread.remove(conversationPeerID)
context.unreadPrivateMessages = unread
}
// The store intent no-ops when no such chat exists.
context.removePrivateChat(PeerID(nostr_: hex))
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
@@ -314,33 +274,12 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
var removedMessage: BitchatMessage?
var removedMessage = context.removePublicMessage(withID: messageID)
if let index = context.messages.firstIndex(where: { $0.id == messageID }) {
removedMessage = context.messages.remove(at: index)
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
removedMessage = removedMessage ?? removedPrivateMessage
}
if let storeRemoved = context.removeTimelineMessage(withID: messageID) {
removedMessage = removedMessage ?? storeRemoved
synchronizeAllPublicConversationStores()
}
var chats = context.privateChats
for (peerID, items) in chats {
let filtered = items.filter { $0.id != messageID }
if filtered.count != items.count {
if filtered.isEmpty {
chats.removeValue(forKey: peerID)
} else {
chats[peerID] = filtered
}
if removedMessage == nil {
removedMessage = items.first(where: { $0.id == messageID })
}
}
}
context.privateChats = chats
if cleanupFile, let removedMessage {
context.cleanupLocalFile(forMessage: removedMessage)
}
@@ -348,46 +287,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.notifyUIChanged()
}
func initializeConversationStore() {
context.setConversationActiveChannel(context.activeChannel)
synchronizePublicConversationStore(for: context.activeChannel)
context.synchronizePrivateConversationStore()
context.synchronizeConversationSelectionStore()
}
func synchronizePublicConversationStore(for channel: ChannelID) {
let publicMessages = context.timelineMessages(for: channel)
context.replaceConversationMessages(publicMessages, for: channel)
if channel == context.activeChannel {
context.setConversationActiveChannel(context.activeChannel)
}
}
func synchronizePublicConversationStore(forGeohash geohash: String) {
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let publicMessages = context.timelineMessages(for: channel)
context.replaceConversationMessages(publicMessages, for: .geohash(geohash.lowercased()))
}
func synchronizeAllPublicConversationStores() {
synchronizePublicConversationStore(for: .mesh)
for geohash in context.timelineGeohashKeys() {
synchronizePublicConversationStore(forGeohash: geohash)
}
}
func refreshVisibleMessages(from channel: ChannelID? = nil) {
let target = channel ?? context.activeChannel
context.messages = context.timelineMessages(for: target)
context.replaceConversationMessages(context.messages, for: target)
if target == context.activeChannel {
context.setConversationActiveChannel(context.activeChannel)
}
}
func clearCurrentPublicTimeline() {
context.messages.removeAll()
context.clearTimeline(for: context.activeChannel)
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
Task.detached(priority: .utility) {
do {
@@ -427,7 +328,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: timestamp,
isRelay: false
)
context.messages.append(systemMessage)
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
}
func addMeshOnlySystemMessage(_ content: String) {
@@ -437,11 +338,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: Date(),
isRelay: false
)
context.appendTimelineMessage(systemMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh)
refreshVisibleMessages()
context.trimMessagesIfNeeded()
context.notifyUIChanged()
context.appendPublicMessage(systemMessage, to: .mesh)
}
func addPublicSystemMessage(_ content: String) {
@@ -451,12 +348,9 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: Date(),
isRelay: false
)
context.appendTimelineMessage(systemMessage, to: context.activeChannel)
refreshVisibleMessages(from: context.activeChannel)
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
let contentKey = context.normalizedContentKey(systemMessage.content)
context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
context.trimMessagesIfNeeded()
context.notifyUIChanged()
}
func addGeohashOnlySystemMessage(_ content: String) {
@@ -506,7 +400,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
if context.isMessageBlocked(finalMessage) { return }
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
let isSystem = finalMessage.sender == "system"
let shouldRateLimit = !isSystem || finalMessage.senderPeerID != nil
if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = context.normalizedContentKey(finalMessage.content)
@@ -515,20 +410,26 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
}
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
if !isSystem && finalMessage.content.count > 16000 { return }
// Empty content never rendered before (the old visible-array enqueue
// filtered it); with the store as the sole timeline it is dropped
// outright instead of lingering invisibly in a backing buffer.
guard !finalMessage.content.trimmed.isEmpty else { return }
if !isGeo && finalMessage.sender != "system" {
context.appendTimelineMessage(finalMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh)
// Resolve the destination conversation. System messages surface on
// the active channel (matching their old visible-only routing); geo
// messages require a current geohash, mesh messages always land in
// the mesh conversation.
let destination: ConversationID?
if isSystem {
destination = ConversationID(channelID: context.activeChannel)
} else if isGeo {
destination = context.currentGeohash.map { .geohash($0.lowercased()) }
} else {
destination = .mesh
}
guard let destination else { return }
if isGeo && finalMessage.sender != "system",
let geohash = context.currentGeohash,
context.appendGeohashMessageIfAbsent(finalMessage, toGeohash: geohash) {
synchronizePublicConversationStore(forGeohash: geohash)
}
let isSystem = finalMessage.sender == "system"
let channelMatches: Bool = {
switch context.activeChannel {
case .mesh: return !isGeo || isSystem
@@ -536,11 +437,16 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
}()
guard channelMatches else { return }
if !finalMessage.content.trimmed.isEmpty,
!context.messages.contains(where: { $0.id == finalMessage.id }) {
context.enqueuePublicMessage(finalMessage)
if channelMatches {
// Visible-channel arrivals are batched: the pipeline's ~80 ms
// flush commits them to the store (which dedups by ID), keeping
// the deliberate UI flush cadence.
guard !context.publicConversationContainsMessage(withID: finalMessage.id, in: destination) else { return }
context.enqueuePublicMessage(finalMessage, to: destination)
} else {
// Background-channel arrivals have no rendering observers to
// batch for; they land in the store immediately.
context.appendPublicMessage(finalMessage, to: destination)
}
}
@@ -598,14 +504,6 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
#endif
}
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
context.messages
}
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
context.messages = messages
}
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
context.normalizedContentKey(content)
}
@@ -618,8 +516,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
context.recordContentKey(key, timestamp: timestamp)
}
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
context.trimMessagesIfNeeded()
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool {
context.appendPublicMessage(message, to: conversationID)
}
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
@@ -15,9 +15,19 @@ protocol ChatTransportEventContext: AnyObject {
var isConnected: Bool { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var unreadPrivateMessages: Set<PeerID> { get set }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get set }
/// Appends a private message via the single-writer store intent;
/// returns `false` on duplicate message ID.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Removes the peer's chat entirely, including unread state.
func removePrivateChat(_ peerID: PeerID)
func markPrivateChatUnread(_ peerID: PeerID)
func markPrivateChatRead(_ peerID: PeerID)
/// Forgets that read receipts were sent for `ids` so READ acks can be
/// re-sent after the peer reconnects. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
@@ -62,7 +72,7 @@ protocol ChatTransportEventContext: AnyObject {
}
extension ChatViewModel: ChatTransportEventContext {
// `isConnected`, `nickname`, `myPeerID`, `privateChats`,
// `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`,
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
// the inbound message handlers, `isPeerBlocked(_:)`,
// `parseMentions(from:)`, `resolveNickname(for:)`,
@@ -225,12 +235,10 @@ final class ChatTransportEventCoordinator {
)
}
if let messages = context.privateChats[peerID] {
let receiptIDs = messages
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
context.notifyUIChanged()
}
@@ -251,13 +259,13 @@ private extension ChatTransportEventCoordinator {
to stablePeerID: PeerID,
in context: any ChatTransportEventContext
) {
if let messages = context.privateChats[shortPeerID] {
if context.privateChats[stablePeerID] == nil {
context.privateChats[stablePeerID] = []
}
let hadUnread = context.unreadPrivateMessages.contains(shortPeerID)
let existingIDs = Set(context.privateChats[stablePeerID]?.map(\.id) ?? [])
for message in messages where !existingIDs.contains(message.id) {
let shortPeerMessages = context.privateMessages(for: shortPeerID)
if !shortPeerMessages.isEmpty {
for message in shortPeerMessages {
// Rewrite senderPeerID to the stable key so read receipts
// keep working; store append dedups by ID and keeps order.
let migrated = BitchatMessage(
id: message.id,
sender: message.sender,
@@ -273,16 +281,15 @@ private extension ChatTransportEventCoordinator {
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
context.privateChats[stablePeerID]?.append(migrated)
context.appendPrivateMessage(migrated, to: stablePeerID)
}
context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
context.privateChats.removeValue(forKey: shortPeerID)
context.removePrivateChat(shortPeerID)
}
if context.unreadPrivateMessages.contains(shortPeerID) {
context.unreadPrivateMessages.remove(shortPeerID)
context.unreadPrivateMessages.insert(stablePeerID)
if hadUnread {
context.markPrivateChatRead(shortPeerID)
context.markPrivateChatUnread(stablePeerID)
}
context.selectedPrivateChatPeer = stablePeerID
+282 -112
View File
@@ -119,9 +119,26 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Published Properties
@Published var messages: [BitchatMessage] = []
/// Read-only derived view of the ACTIVE public channel's conversation in
/// the single-writer `ConversationStore`. SwiftUI renders through
/// `PublicChatModel` (which observes the `Conversation` object directly);
/// this view serves the coordinators/commands that need "the visible
/// timeline" plus tests. Hot enough that the array is cached and
/// invalidated from the store's `changes` subject (filtered to the
/// active conversation) and on channel switches. `objectWillChange`
/// fires on every store change via the sink in `init`.
@MainActor
var messages: [BitchatMessage] {
if let cached = visibleMessagesCache { return cached }
// Read-only lookup (never creates the conversation): this getter
// runs during SwiftUI renders, where mutating the store's
// `@Published` collections would publish mid-view-update.
let current = conversations.conversationsByID[ConversationID(channelID: activeChannel)]?.messages ?? []
visibleMessagesCache = current
return current
}
private var visibleMessagesCache: [BitchatMessage]?
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false
@Published var nickname: String = "" {
didSet {
@@ -164,13 +181,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
@Published var allPeers: [BitchatPeer] = []
/// Read-only derived view of all direct conversations in the
/// `ConversationStore`, keyed by routing peer ID. Serves the coordinator
/// reads that genuinely need the whole dictionary (migration scans,
/// unread resolution); simple per-peer reads go through
/// `privateMessages(for:)` instead. All mutations go through the
/// private-chat intent ops below. Rebuilt per access
/// O(#conversations) thanks to COW message arrays; measured equal to a
/// change-invalidated cache on `pipeline.privateIngest`, so the simpler
/// form wins.
@MainActor
var privateChats: [PeerID: [BitchatMessage]] {
get { privateChatManager.privateChats }
set {
privateChatManager.privateChats = newValue
schedulePrivateConversationStoreSynchronization()
}
conversations.directMessagesByRoutingPeerID()
}
@MainActor
var selectedPrivateChatPeer: PeerID? {
get { privateChatManager.selectedPeer }
set {
@@ -179,19 +204,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
} else {
privateChatManager.endChat()
}
synchronizePrivateConversationStore()
synchronizeConversationSelectionStore()
}
}
/// Read-only derived view of the store's unread direct conversations.
/// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`.
@MainActor
var unreadPrivateMessages: Set<PeerID> {
get { privateChatManager.unreadMessages }
set {
privateChatManager.unreadMessages = newValue
schedulePrivateConversationStoreSynchronization()
}
conversations.unreadDirectRoutingPeerIDs()
}
/// Check if there are any unread messages (including from temporary Nostr peer IDs)
@MainActor
var hasAnyUnreadMessages: Bool {
!unreadPrivateMessages.isEmpty
}
@@ -270,8 +294,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let meshService: Transport
let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol
let conversationStore: ConversationStore
let identityResolver: IdentityResolver
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
/// through.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let locationManager: LocationChannelManager
@@ -282,13 +308,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID {
get { conversationStore.activeChannel }
get { conversations.activeChannel }
set {
guard conversationStore.activeChannel != newValue else { return }
publicMessagePipeline.updateActiveChannel(newValue)
conversationStore.setActiveChannel(newValue)
synchronizePublicConversationStore(for: newValue)
synchronizeConversationSelectionStore()
guard conversations.activeChannel != newValue else { return }
conversations.setActiveChannel(newValue)
visibleMessagesCache = nil
objectWillChange.send()
}
}
@@ -339,11 +363,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
var timelineStore = PublicTimelineStore(
meshCap: TransportConfig.meshTimelineCap,
geohashCap: TransportConfig.geoTimelineCap
)
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread {
MainActor.assumeIsolated {
@@ -374,7 +393,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Delivery Tracking
var cancellables = Set<AnyCancellable>()
private var pendingPrivateConversationStoreSyncTask: Task<Void, Never>?
var transferIdToMessageIDs: [String: [String]] {
mediaTransferCoordinator.transferIdToMessageIDs
@@ -525,6 +543,185 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
selectedPrivateChatPeer = newPeerID
}
// MARK: - Private Conversation Store Intents
// The sole mutation paths for private (direct) message state. Each op
// forwards to the single-writer `ConversationStore`
// (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` /
// `unreadPrivateMessages` views above are derived from the same store.
/// Appends a private message in timestamp order. Returns `false` when a
/// message with the same ID is already in that chat (O(1) dedup via the
/// conversation's ID index).
@MainActor
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
conversations.append(message, to: .directPeer(peerID))
}
/// Replace-or-append a private message by ID (media progress, mirrored
/// copies); an existing message keeps its timeline position.
@MainActor
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
conversations.upsertByID(message, in: .directPeer(peerID))
}
/// Applies a delivery status to a private message by ID. Returns `false`
/// when the message is unknown or the update would downgrade the status
/// (read beats delivered beats sent).
@MainActor
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
conversations.setDeliveryStatus(status, forMessageID: messageID, in: .directPeer(peerID))
}
/// Flags the peer's chat as unread (store unread state).
@MainActor
func markPrivateChatUnread(_ peerID: PeerID) {
conversations.markUnread(.directPeer(peerID))
}
/// Clears the peer's unread flag (store unread state only; read-receipt
/// sending stays in `PrivateChatManager.markAsRead`).
@MainActor
func markPrivateChatRead(_ peerID: PeerID) {
conversations.markRead(.directPeer(peerID))
}
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
@MainActor
func clearPrivateChat(_ peerID: PeerID) {
conversations.clear(.directPeer(peerID))
}
/// Removes the peer's chat entirely, including unread state.
@MainActor
func removePrivateChat(_ peerID: PeerID) {
conversations.removeConversation(.directPeer(peerID))
}
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (ephemeralstable peer-ID handoff): dedups by ID, preserves order,
/// carries unread state, removes the old chat.
@MainActor
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID))
}
/// A single private chat's timeline, read straight from the store
/// an O(1) lookup that skips the `privateChats` dictionary build. The
/// context protocols' simple per-peer reads dispatch here.
@MainActor
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
/// `true` when any private chat contains a message with `messageID`
/// (O(1) per conversation via the store's ID indexes).
@MainActor
func privateChatsContainMessage(withID messageID: String) -> Bool {
conversations.directConversationsContainMessage(withID: messageID)
}
/// `true` when `peerID`'s chat contains a message with `messageID`.
@MainActor
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool {
conversations.conversationsByID[.directPeer(peerID)]?.containsMessage(withID: messageID) ?? false
}
/// Removes a message by ID from every private chat that contains it,
/// dropping chats that become empty. Returns the removed message, if any.
@MainActor
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
var removed: BitchatMessage?
for (id, conversation) in conversations.conversationsByID {
guard case .direct = id, conversation.containsMessage(withID: messageID) else { continue }
let message = conversations.removeMessage(withID: messageID, from: id)
removed = removed ?? message
if conversation.messages.isEmpty {
conversations.removeConversation(id)
}
}
return removed
}
// MARK: - Public Conversation Store Intents
// The sole mutation paths for public (mesh/geohash) message state,
// mirroring the private intents above. The store's per-conversation cap
// and timestamp-ordered insert replace `PublicTimelineStore`'s trim and
// the pipeline's late-insert positioning; the read-only `messages` shim
// above is derived from the same store.
/// Appends a public message in timestamp order. Returns `false` when a
/// message with the same ID is already in that conversation (O(1) dedup
/// via the conversation's ID index).
@MainActor
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
conversations.append(message, to: conversationID)
}
/// Appends a geohash message if absent. Returns `true` when stored
/// (the legacy `PublicTimelineStore.appendIfAbsent` contract).
@MainActor
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
conversations.append(message, to: .geohash(geohash.lowercased()))
}
/// A public (mesh/geohash) channel's full timeline.
@MainActor
func publicMessages(for channel: ChannelID) -> [BitchatMessage] {
conversations.conversation(for: ConversationID(channelID: channel)).messages
}
/// `true` when the conversation contains a message with `messageID`.
@MainActor
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool {
conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false
}
/// Removes a message by ID from whichever public conversation contains
/// it. Returns the removed message, if any.
@MainActor
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
conversations.removePublicMessage(withID: messageID)
}
/// Removes every message matching `predicate` from a geohash
/// conversation (block-user purge).
@MainActor
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
conversations.removeMessages(from: .geohash(geohash.lowercased()), where: predicate)
}
/// Empties a public conversation's timeline (`/clear`).
@MainActor
func clearPublicConversation(_ conversationID: ConversationID) {
conversations.clear(conversationID)
}
/// Queues a system message for the next geohash channel visit. (Tiny
/// UI-flow queue formerly on `PublicTimelineStore`; it is notice text,
/// not conversation state, so it stays on the owner.)
@MainActor
func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
/// Drains the queued geohash system messages (single consumer:
/// `GeohashSubscriptionManager.switchLocationChannel`).
@MainActor
func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
// Single-writer: mutate only via `queueGeohashSystemMessage(_:)` /
// `drainPendingGeohashSystemMessages()` above.
private var pendingGeohashSystemMessages: [String] = []
// MARK: - Initialization
@MainActor
@@ -532,21 +729,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
conversationStore: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let conversationStore = conversationStore ?? ConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
self.init(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
conversationStore: conversationStore,
identityResolver: identityResolver,
conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager
@@ -561,14 +754,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
transport: Transport,
conversationStore: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let conversationStore = conversationStore ?? ConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
let services = ChatViewModelServiceBundle(
@@ -581,8 +772,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.keychain = keychain
self.idBridge = idBridge
self.identityManager = identityManager
self.conversationStore = conversationStore
self.identityResolver = identityResolver
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.locationManager = locationManager
@@ -596,8 +786,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.publicMessagePipeline = services.publicMessagePipeline
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts()
// Republish on every store change so SwiftUI observers of the
// view model refresh. This replaces the UI-update role of the old
// `PrivateChatManager.@Published` dictionaries and the old
// `@Published var messages`. Changes touching the ACTIVE public
// conversation also invalidate the derived `messages` cache before
// observers re-read it.
conversations.changes
.sink { [weak self] change in
guard let self else { return }
if self.changeAffectsActivePublicConversation(change) {
self.visibleMessagesCache = nil
}
self.objectWillChange.send()
}
.store(in: &cancellables)
ChatViewModelBootstrapper(viewModel: self).configure()
initializeConversationStore()
synchronizeConversationSelectionStore()
}
// MARK: - Deinitialization
@@ -789,8 +995,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID
)
if privateChats[peerID] == nil { privateChats[peerID] = [] }
privateChats[peerID]?.append(systemMessage)
appendPrivateMessage(systemMessage, to: peerID)
objectWillChange.send()
}
@@ -879,14 +1084,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func panicClearAllData() {
// Messages are processed immediately - nothing to flush
// Clear all messages
messages.removeAll()
timelineStore = PublicTimelineStore(
meshCap: TransportConfig.meshTimelineCap,
geohashCap: TransportConfig.geoTimelineCap
)
privateChatManager.privateChats.removeAll()
privateChatManager.unreadMessages.removeAll()
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
conversations.clearAll()
pendingGeohashSystemMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys)
_ = keychain.deleteAllKeychainData()
@@ -938,7 +1140,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
bleService.resetIdentityForPanic(currentNickname: nickname)
}
initializeConversationStore()
synchronizeConversationSelectionStore()
// No need to force UserDefaults synchronization
@@ -1060,64 +1262,41 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Handling
@MainActor
func initializeConversationStore() {
publicConversationCoordinator.initializeConversationStore()
}
@MainActor
func synchronizePublicConversationStore(for channel: ChannelID) {
publicConversationCoordinator.synchronizePublicConversationStore(for: channel)
}
@MainActor
func synchronizePublicConversationStore(forGeohash geohash: String) {
publicConversationCoordinator.synchronizePublicConversationStore(forGeohash: geohash)
}
@MainActor
func synchronizeAllPublicConversationStores() {
publicConversationCoordinator.synchronizeAllPublicConversationStores()
}
@MainActor
func schedulePrivateConversationStoreSynchronization() {
guard pendingPrivateConversationStoreSyncTask == nil else { return }
pendingPrivateConversationStoreSyncTask = Task { @MainActor [weak self] in
await Task.yield()
guard let self else { return }
self.pendingPrivateConversationStoreSyncTask = nil
self.synchronizePrivateConversationStore()
}
}
@MainActor
func synchronizePrivateConversationStore() {
conversationStore.synchronizePrivateChats(
privateChatManager.privateChats,
unreadPeerIDs: privateChatManager.unreadMessages,
identityResolver: identityResolver
)
}
/// Pushes the private-chat manager's selection into the store, which
/// derives `selectedConversationID` from it and the active channel.
@MainActor
func synchronizeConversationSelectionStore() {
conversationStore.setSelectedPeerID(
privateChatManager.selectedPeer,
activeChannel: activeChannel,
identityResolver: identityResolver
)
}
func trimMessagesIfNeeded() {
if messages.count > maxMessages {
messages = Array(messages.suffix(maxMessages))
}
conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer)
}
/// Invalidates the derived `messages` cache and notifies observers.
/// (Formerly pulled the channel's timeline into a stored `messages`
/// array; `messages` is now derived from the `ConversationStore`, so
/// only the invalidation remains. The `channel` parameter is kept for
/// call-site compatibility every caller passes the active channel.)
@MainActor
func refreshVisibleMessages(from channel: ChannelID? = nil) {
publicConversationCoordinator.refreshVisibleMessages(from: channel)
visibleMessagesCache = nil
objectWillChange.send()
}
/// `true` when a store change touches the active public conversation
/// (so the derived `messages` cache must be invalidated).
@MainActor
private func changeAffectsActivePublicConversation(_ change: ConversationChange) -> Bool {
let activeID = ConversationID(channelID: activeChannel)
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id),
.removed(let id),
.unreadChanged(let id, _):
return id == activeID
case .migrated(let source, let destination):
return source == activeID || destination == activeID
}
}
@MainActor
@@ -1159,15 +1338,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.clearCurrentPublicTimeline()
}
// MARK: - Message Management
private func addMessage(_ message: BitchatMessage) {
// Check for duplicates
guard !messages.contains(where: { $0.id == message.id }) else { return }
messages.append(message)
trimMessagesIfNeeded()
}
// MARK: - Peer Lookup Helpers
func getPeer(byID peerID: PeerID) -> BitchatPeer? {
@@ -74,6 +74,7 @@ final class ChatViewModelBootstrapper {
private extension ChatViewModelBootstrapper {
func wireServiceGraph() {
viewModel.privateChatManager.conversationStore = viewModel.conversations
viewModel.privateChatManager.messageRouter = viewModel.messageRouter
viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService
viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter
@@ -89,24 +90,9 @@ private extension ChatViewModelBootstrapper {
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$privateChats
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.schedulePrivateConversationStoreSynchronization()
}
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$unreadMessages
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.schedulePrivateConversationStoreSynchronization()
}
}
.store(in: &viewModel.cancellables)
// Private message state flows through the single-writer
// `ConversationStore` intents and its `changes` subject; only the
// selection still originates in `PrivateChatManager`.
viewModel.privateChatManager.$selectedPeer
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
@@ -144,7 +130,6 @@ private extension ChatViewModelBootstrapper {
viewModel.meshService.startServices()
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
@@ -173,7 +158,6 @@ private extension ChatViewModelBootstrapper {
guard let viewModel else { return }
viewModel.allPeers = peers
viewModel.identityResolver.register(peers: peers)
var uniquePeers: [PeerID: BitchatPeer] = [:]
for peer in peers {
@@ -192,7 +176,6 @@ private extension ChatViewModelBootstrapper {
viewModel.updatePrivateChatPeerIfNeeded()
}
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
}
}
+7 -9
View File
@@ -23,8 +23,10 @@ protocol GeoPresenceContext: AnyObject {
func geoParticipantCount(for geohash: String) -> Int
func markGeoTeleported(_ pubkeyHexLowercased: String)
/// Appends a geohash message if absent (single-writer store intent).
/// Returns `true` when stored.
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
func synchronizePublicConversationStore(forGeohash geohash: String)
/// Posts the sampled-geohash-activity local notification.
func notifyGeohashActivity(geohash: String, bodyPreview: String)
@@ -32,13 +34,10 @@ protocol GeoPresenceContext: AnyObject {
extension ChatViewModel: GeoPresenceContext {
// `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr
// identity/blocking members, and `synchronizePublicConversationStore`
// already have witnesses on `ChatViewModel`. The members below flatten
// nested service accesses into intent-named calls.
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
timelineStore.appendIfAbsent(message, toGeohash: geohash)
}
// identity/blocking members, and the
// `appendGeohashMessageIfAbsent(_:toGeohash:)` store intent already have
// witnesses on `ChatViewModel`. The members below flatten nested service
// accesses into intent-named calls.
var teleportedGeoCount: Int {
locationPresenceStore.teleportedGeo.count
@@ -166,7 +165,6 @@ final class GeoPresenceTracker {
mentions: mentions.isEmpty ? nil : mentions
)
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
context.synchronizePublicConversationStore(forGeohash: gh)
context.notifyGeohashActivity(geohash: gh, bodyPreview: preview)
}
}
@@ -27,8 +27,9 @@ protocol GeohashSubscriptionContext: AnyObject {
// MARK: Public timeline & pipeline
var messages: [BitchatMessage] { get }
func resetPublicMessagePipeline()
func updatePublicMessagePipelineChannel(_ channel: ChannelID)
/// Commits any batched-but-unflushed public messages to the store so a
/// channel switch never strands them in the pipeline buffer.
func flushPublicMessagePipeline()
func refreshVisibleMessages(from channel: ChannelID?)
func addPublicSystemMessage(_ content: String)
func drainPendingGeohashSystemMessages() -> [String]
@@ -64,16 +65,8 @@ extension ChatViewModel: GeohashSubscriptionContext {
// `ChatViewModel`. The members below flatten nested service accesses into
// intent-named calls.
func resetPublicMessagePipeline() {
publicMessagePipeline.reset()
}
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {
publicMessagePipeline.updateActiveChannel(channel)
}
func drainPendingGeohashSystemMessages() -> [String] {
timelineStore.drainPendingGeohashSystemMessages()
func flushPublicMessagePipeline() {
publicMessagePipeline.flushIfNeeded()
}
func clearProcessedNostrEvents() {
@@ -178,9 +171,8 @@ final class GeohashSubscriptionManager {
@MainActor
func switchLocationChannel(to channel: ChannelID) {
guard let context else { return }
context.resetPublicMessagePipeline()
context.flushPublicMessagePipeline()
context.activeChannel = channel
context.updatePublicMessagePipelineChannel(channel)
context.clearProcessedNostrEvents()
switch channel {
+26 -72
View File
@@ -2,7 +2,11 @@
// PublicMessagePipeline.swift
// bitchat
//
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
// Batches visible-channel public messages before committing them to the
// ConversationStore: the deliberate ~80 ms UI flush cadence survives the
// store cutover, while ordering, dedup, and caps live in the store itself
// (its timestamp-ordered insert replaced this pipeline's late-insert
// threshold positioning; see docs/CONVERSATION-STORE-DESIGN.md).
//
import BitFoundation
@@ -10,12 +14,13 @@ import Foundation
@MainActor
protocol PublicMessagePipelineDelegate: AnyObject {
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
/// Commits a batched message to its conversation in the store.
/// Returns `false` when the message was already present (ID dedup).
@discardableResult
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
}
@@ -24,14 +29,13 @@ protocol PublicMessagePipelineDelegate: AnyObject {
final class PublicMessagePipeline {
weak var delegate: PublicMessagePipelineDelegate?
private var buffer: [BitchatMessage] = []
private var buffer: [(message: BitchatMessage, conversationID: ConversationID)] = []
private var timer: Timer?
private let baseFlushInterval: TimeInterval
private var dynamicFlushInterval: TimeInterval
private var recentBatchSizes: [Int] = []
private let maxRecentBatchSamples: Int
private let dedupWindow: TimeInterval
private var activeChannel: ChannelID = .mesh
init(
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
@@ -48,25 +52,17 @@ final class PublicMessagePipeline {
timer?.invalidate()
}
func updateActiveChannel(_ channel: ChannelID) {
activeChannel = channel
}
func enqueue(_ message: BitchatMessage) {
buffer.append(message)
/// Buffers a message destined for `conversationID`; the next batched
/// flush commits it to the store. Each entry carries its destination so
/// a channel switch mid-batch can never misroute buffered messages.
func enqueue(_ message: BitchatMessage, to conversationID: ConversationID) {
buffer.append((message, conversationID))
scheduleFlush()
}
func flushIfNeeded() {
flushBuffer()
}
func reset() {
timer?.invalidate()
timer = nil
buffer.removeAll(keepingCapacity: false)
}
}
private extension PublicMessagePipeline {
@@ -91,57 +87,38 @@ private extension PublicMessagePipeline {
delegate.pipelineSetBatchingState(self, isBatching: true)
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
var pending: [(message: BitchatMessage, contentKey: String)] = []
// Content-window dedup against recorded keys and within the batch;
// ID dedup happens in the store at commit time.
var pending: [(message: BitchatMessage, conversationID: ConversationID, contentKey: String)] = []
var batchContentLatest: [String: Date] = [:]
for message in buffer {
if existingIDs.contains(message.id) { continue }
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
for item in buffer {
let contentKey = delegate.pipeline(self, normalizeContent: item.message.content)
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow {
continue
}
if let ts = batchContentLatest[contentKey],
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow {
continue
}
existingIDs.insert(message.id)
pending.append((message, contentKey))
batchContentLatest[contentKey] = message.timestamp
pending.append((item.message, item.conversationID, contentKey))
batchContentLatest[contentKey] = item.message.timestamp
}
buffer.removeAll(keepingCapacity: true)
guard !pending.isEmpty else {
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty { scheduleFlush() }
return
}
pending.sort { $0.message.timestamp < $1.message.timestamp }
var messages = delegate.pipelineCurrentMessages(self)
let threshold = lateInsertThreshold(for: activeChannel)
let lastTimestamp = messages.last?.timestamp ?? .distantPast
for item in pending {
let message = item.message
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
let index = insertionIndex(for: message.timestamp, in: messages)
if index >= messages.count {
messages.append(message)
} else {
messages.insert(message, at: index)
}
} else {
messages.append(message)
}
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
guard delegate.pipeline(self, commit: item.message, to: item.conversationID) else { continue }
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: item.message.timestamp)
}
delegate.pipeline(self, setMessages: messages)
delegate.pipelineTrimMessages(self)
updateFlushInterval(withBatchSize: pending.count)
for item in pending {
@@ -165,27 +142,4 @@ private extension PublicMessagePipeline {
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
}
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
switch channel {
case .mesh:
return TransportConfig.uiLateInsertThreshold
case .location:
return TransportConfig.uiLateInsertThresholdGeo
}
}
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
}
@@ -1,148 +0,0 @@
//
// PublicTimelineStore.swift
// bitchat
//
// Maintains mesh and geohash public timelines with simple caps and helpers.
//
import BitFoundation
import Foundation
struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = []
private var meshMessageIDs: Set<String> = []
private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var geohashMessageIDs: [String: Set<String>] = [:]
private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int
private let geohashCap: Int
init(meshCap: Int, geohashCap: Int) {
self.meshCap = meshCap
self.geohashCap = geohashCap
}
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh:
guard !meshMessageIDs.contains(message.id) else { return }
meshTimeline.append(message)
meshMessageIDs.insert(message.id)
trimMeshTimelineIfNeeded()
case .location(let channel):
append(message, toGeohash: channel.geohash)
}
}
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
_ = appendGeohashMessageIfAbsent(message, geohash: geohash)
}
/// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
appendGeohashMessageIfAbsent(message, geohash: geohash)
}
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh:
return meshTimeline
case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
replaceGeohashTimeline(cleaned, for: channel.geohash, keepEmpty: true)
return cleaned
}
}
mutating func clear(channel: ChannelID) {
switch channel {
case .mesh:
meshTimeline.removeAll()
meshMessageIDs.removeAll()
case .location(let channel):
geohashTimelines[channel.geohash] = []
geohashMessageIDs[channel.geohash] = []
}
}
@discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
let removed = meshTimeline.remove(at: index)
meshMessageIDs.remove(id)
return removed
}
for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index)
replaceGeohashTimeline(timeline, for: key, keepEmpty: false)
return removed
}
}
return nil
}
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate)
replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false)
}
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? []
transform(&timeline)
replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false)
}
mutating func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
mutating func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
func geohashKeys() -> [String] {
Array(geohashTimelines.keys)
}
private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap))
meshMessageIDs = Set(meshTimeline.map(\.id))
}
private mutating func appendGeohashMessageIfAbsent(_ message: BitchatMessage, geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? []
var messageIDs = geohashMessageIDs[geohash] ?? Set(timeline.map(\.id))
guard messageIDs.insert(message.id).inserted else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline, messageIDs: &messageIDs)
geohashTimelines[geohash] = timeline
geohashMessageIDs[geohash] = messageIDs
return true
}
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage], messageIDs: inout Set<String>) {
guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap))
messageIDs = Set(timeline.map(\.id))
}
private mutating func replaceGeohashTimeline(_ timeline: [BitchatMessage], for geohash: String, keepEmpty: Bool) {
if timeline.isEmpty && !keepEmpty {
geohashTimelines[geohash] = nil
geohashMessageIDs[geohash] = nil
return
}
geohashTimelines[geohash] = timeline
geohashMessageIDs[geohash] = Set(timeline.map(\.id))
}
}
@@ -81,7 +81,7 @@ struct CommandSuggestionsView: View {
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
let locationChannelsModel = LocationChannelsModel()
@@ -76,12 +76,12 @@ struct TextMessageView: View {
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
Group {
+152 -209
View File
@@ -1,4 +1,5 @@
import BitFoundation
import Combine
import Foundation
import Testing
@testable import bitchat
@@ -45,6 +46,27 @@ private func makeArchitectureSnapshot(
)
}
@MainActor
private func makeArchitectureMessage(
id: String,
timestamp: TimeInterval = 0,
content: String? = nil,
isPrivate: Bool = false,
senderPeerID: PeerID = PeerID(str: "peer-a")
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: content ?? "message \(id)",
timestamp: Date(timeIntervalSince1970: timestamp),
isRelay: false,
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: isPrivate ? "builder" : nil,
senderPeerID: senderPeerID
)
}
@MainActor
private func waitUntil(
timeoutNanoseconds: UInt64 = 3_000_000_000,
@@ -127,251 +149,119 @@ struct AppArchitectureTests {
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(
id: "noise:abc123",
routingPeerID: PeerID(str: "peer-a"),
displayName: "alice",
noisePublicKeyHex: "abc123",
nostrPublicKey: nil
)
let second = PeerHandle(
id: "noise:abc123",
routingPeerID: PeerID(str: "peer-b"),
displayName: "alice-renamed",
noisePublicKeyHex: nil,
nostrPublicKey: "npub123"
)
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b"))
#expect(first == second)
#expect(Set([first, second]).count == 1)
}
@Test("ConversationStore normalizes timeline ordering and duplicates")
@Test("ConversationStore orders timelines and replaces duplicates by message ID")
@MainActor
func conversationStoreNormalizesMessages() {
func conversationStoreOrdersAndDedupsMessages() {
let store = ConversationStore()
let older = BitchatMessage(
id: "m1",
sender: "alice",
content: "first",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let newer = BitchatMessage(
id: "m2",
sender: "alice",
content: "second",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let replacement = BitchatMessage(
id: "m2",
sender: "alice",
content: "second-updated",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first")
let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second")
let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated")
store.replaceMessages([newer, older, replacement], for: ConversationID.mesh)
store.append(newer, to: .mesh)
store.append(older, to: .mesh)
store.upsertByID(replacement, in: .mesh)
let messages = store.messages(for: ConversationID.mesh)
let messages = store.conversation(for: .mesh).messages
#expect(messages.map(\.id) == ["m1", "m2"])
#expect(messages.last?.content == "second-updated")
}
@Test("ConversationStore tracks unread direct conversations with canonical IDs")
@Test("ConversationStore tracks unread direct conversations by routing peer ID")
@MainActor
func conversationStoreTracksUnreadDirectConversations() {
let store = ConversationStore()
let resolver = IdentityResolver()
let peerID = PeerID(str: "peer-1")
let message = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "bob",
senderPeerID: peerID
)
let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID)
store.synchronizePrivateChats(
[peerID: [message]],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
store.append(message, to: .directPeer(peerID))
store.markUnread(.directPeer(peerID))
let conversationID = ConversationID.direct(
resolver.canonicalHandle(for: peerID, displayName: "alice")
)
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"])
#expect(store.unreadDirectRoutingPeerIDs() == Set([peerID]))
#expect(store.conversation(for: .directPeer(peerID)).isUnread)
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"])
#expect(store.unreadConversations.contains(conversationID))
store.markRead(conversationID)
#expect(!store.unreadConversations.contains(conversationID))
store.markRead(.directPeer(peerID))
#expect(store.unreadDirectRoutingPeerIDs().isEmpty)
#expect(!store.conversation(for: .directPeer(peerID)).isUnread)
}
@Test("ConversationStore tracks the selected app conversation context")
@Test("ConversationStore derives the selected conversation from channel and private peer")
@MainActor
func conversationStoreTracksSelectedConversationContext() {
let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let peerID = PeerID(str: "0011223344556677")
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
let peer = BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
resolver.register(peers: [peer])
store.synchronizeSelection(
activeChannel: geohashChannel,
selectedPeerID: shortPeerID,
identityResolver: resolver
)
let expectedConversationID = ConversationID.direct(
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
)
store.setActiveChannel(geohashChannel)
store.setSelectedPrivatePeer(peerID)
#expect(store.activeChannel == geohashChannel)
#expect(store.selectedPrivatePeerID == shortPeerID)
#expect(store.selectedConversationID == expectedConversationID)
#expect(store.selectedPrivatePeerID == peerID)
// The open private chat wins the derived selection.
#expect(store.selectedConversationID == ConversationID.directPeer(peerID))
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: nil,
identityResolver: resolver
)
store.setSelectedPrivatePeer(nil)
// Selection falls back to the active public channel.
#expect(store.selectedConversationID == ConversationID(channelID: geohashChannel))
store.setActiveChannel(.mesh)
#expect(store.activeChannel == ChannelID.mesh)
#expect(store.selectedPrivatePeerID == nil)
#expect(store.selectedConversationID == ConversationID.mesh)
}
@Test("ConversationStore exposes direct conversations by the latest routing peer ID")
@Test("ConversationStore re-keys a direct conversation via the migrate intent")
@MainActor
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
func conversationStoreMigratesDirectConversationsBetweenPeerIDs() {
let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey)
let firstMessage = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "short id",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: shortPeerID
)
let secondMessage = BitchatMessage(
id: "dm-2",
sender: "alice",
content: "full id",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: fullPeerID
)
resolver.register(
peer: BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[shortPeerID: [firstMessage]],
unreadPeerIDs: Set([shortPeerID]),
identityResolver: resolver
store.append(
makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID),
to: .directPeer(shortPeerID)
)
store.markUnread(.directPeer(shortPeerID))
store.setSelectedPrivatePeer(shortPeerID)
resolver.register(
peer: BitchatPeer(
peerID: fullPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[fullPeerID: [secondMessage]],
unreadPeerIDs: Set([fullPeerID]),
identityResolver: resolver
)
store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID))
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID]))
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"])
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
// Raw keying: the old peer's conversation is gone, the new peer's
// conversation holds the timeline, unread and selection carried over.
#expect(store.conversationsByID[.directPeer(shortPeerID)] == nil)
#expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID]))
#expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"])
#expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID]))
#expect(store.selectedPrivatePeerID == fullPeerID)
#expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID))
}
@Test("PrivateInboxModel mirrors direct message state from ConversationStore")
@Test("PrivateInboxModel reads direct message state from the ConversationStore")
@MainActor
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
func privateInboxModelReadsDirectMessageStateFromConversationStore() {
let store = ConversationStore()
let resolver = IdentityResolver()
let inboxModel = PrivateInboxModel(conversationStore: store)
let inboxModel = PrivateInboxModel(conversations: store)
let messagePeerID = PeerID(str: "peer-1")
let unreadOnlyPeerID = PeerID(str: "peer-2")
let selectedOnlyPeerID = PeerID(str: "peer-3")
let message = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: messagePeerID
)
store.synchronizePrivateChats(
[messagePeerID: [message]],
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]),
identityResolver: resolver
)
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: selectedOnlyPeerID,
identityResolver: resolver
store.append(
makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID),
to: .directPeer(messagePeerID)
)
store.markUnread(.directPeer(messagePeerID))
store.markUnread(.directPeer(unreadOnlyPeerID))
store.setSelectedPrivatePeer(selectedOnlyPeerID)
await waitUntil {
inboxModel.selectedPeerID == selectedOnlyPeerID &&
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
}
// Reads are synchronous against the single-writer store.
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
@@ -379,13 +269,74 @@ struct AppArchitectureTests {
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
}
@Test("PrivateInboxModel republishes only for the selected conversation")
@MainActor
func privateInboxModelIsolatesBackgroundConversations() {
let store = ConversationStore()
let inboxModel = PrivateInboxModel(conversations: store)
let selectedPeerID = PeerID(str: "peer-selected")
let backgroundPeerID = PeerID(str: "peer-background")
store.setSelectedPrivatePeer(selectedPeerID)
var emissions = 0
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
let baseline = emissions
store.append(
makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID),
to: .directPeer(backgroundPeerID)
)
// An append to a background chat does not republish the model.
#expect(emissions == baseline)
store.append(
makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID),
to: .directPeer(selectedPeerID)
)
#expect(emissions == baseline + 1)
#expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"])
}
@Test("PublicChatModel ignores appends to background conversations")
@MainActor
func publicChatModelIsolatesBackgroundConversations() {
let store = ConversationStore()
store.setActiveChannel(.mesh)
let model = PublicChatModel(conversations: store)
var emissions = 0
let cancellable = model.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh)
let afterActiveAppend = emissions
#expect(afterActiveAppend >= 1)
#expect(model.messages.map(\.id) == ["mesh-1"])
// Appends to a background geohash channel and to a private chat do
// not invalidate the observer of the active conversation.
store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd"))
store.append(
makeArchitectureMessage(id: "dm-1", isPrivate: true),
to: .directPeer(PeerID(str: "peer-1"))
)
#expect(emissions == afterActiveAppend)
#expect(model.messages.map(\.id) == ["mesh-1"])
// Switching the channel retargets the observation.
store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")))
#expect(model.messages.map(\.id) == ["geo-1"])
store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd"))
#expect(model.messages.map(\.id) == ["geo-1", "geo-2"])
}
@Test("AppChromeModel mirrors nickname and unread state through focused models")
@MainActor
func appChromeModelMirrorsNicknameAndUnreadState() async {
let viewModel = makeArchitectureViewModel()
let conversationStore = ConversationStore()
let resolver = IdentityResolver()
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let conversations = ConversationStore()
let privateInboxModel = PrivateInboxModel(conversations: conversations)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
chromeModel.setNickname("builder")
@@ -398,11 +349,7 @@ struct AppArchitectureTests {
#expect(!chromeModel.hasUnreadPrivateMessages)
let peerID = PeerID(str: "peer-1")
conversationStore.synchronizePrivateChats(
[:],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
conversations.markUnread(.directPeer(peerID))
await waitUntil {
chromeModel.hasUnreadPrivateMessages
}
@@ -414,8 +361,7 @@ struct AppArchitectureTests {
@MainActor
func appChromeModelOwnsPresentationState() {
let viewModel = makeArchitectureViewModel()
let conversationStore = ConversationStore()
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
let peerID = PeerID(str: "peer-2")
@@ -441,11 +387,10 @@ struct AppArchitectureTests {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let conversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
@@ -493,18 +438,17 @@ struct AppArchitectureTests {
return
}
let conversationStore = viewModel.conversationStore
locationManager.select(.mesh)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let uiModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: conversationStore
conversations: viewModel.conversations
)
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
defer {
@@ -558,11 +502,10 @@ struct AppArchitectureTests {
let peerID = PeerID(str: "0011223344556677")
let fingerprint = "verified-fingerprint"
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
@@ -631,7 +574,7 @@ struct AppArchitectureTests {
transport.reachablePeers.insert(otherPeerID)
viewModel.nickname = "builder"
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
viewModel.unreadPrivateMessages = Set([otherPeerID])
viewModel.markPrivateChatUnread(otherPeerID)
transport.updatePeerSnapshots([
makeArchitectureSnapshot(
peerID: myPeerID,
@@ -664,7 +607,7 @@ struct AppArchitectureTests {
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
@@ -29,6 +29,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
// Chat & receipt state
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var sentReadReceipts: Set<String> = []
@@ -38,9 +42,23 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var ownerLevelReadPasses: [PeerID] = []
private(set) var managerReadMarks: [PeerID] = []
private(set) var privateStoreSyncCount = 0
private(set) var systemMessages: [String] = []
// Conversation store intents
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else { return false }
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
chat.insert(message, at: index)
privateChats[peerID] = chat
return true
}
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
@@ -61,7 +79,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
work()
}
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Peers & sessions
@@ -234,7 +251,6 @@ struct ChatLifecycleCoordinatorContextTests {
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.managerReadMarks == [convKey])
#expect(context.privateStoreSyncCount == 1)
// Only the peer's own un-acked, non-relay message gets a READ.
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
@@ -42,23 +42,27 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Message state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var meshTimeline: [BitchatMessage] = []
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var trimCount = 0
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else { return false }
chat.append(message)
privateChats[peerID] = chat
return true
}
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
private(set) var systemMessages: [String] = []
private(set) var notifyUIChangedCount = 0
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
meshTimeline.append(message)
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
appendedPublicMessages.append((message, conversationID))
return true
}
func refreshVisibleMessages(from channel: ChannelID?) {
refreshedChannels.append(channel)
}
func trimMessagesIfNeeded() { trimCount += 1 }
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessages.append((messageID, cleanupFile))
}
@@ -119,22 +123,21 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(message.senderPeerID == context.myPeerID)
#expect(message.deliveryStatus == .sending)
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
#expect(context.trimCount == 1)
#expect(context.notifyUIChangedCount == 1)
#expect(context.meshTimeline.isEmpty)
#expect(context.appendedPublicMessages.isEmpty)
}
@Test @MainActor
func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async {
func enqueueMediaMessage_publicAppendsToActiveConversation() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil)
#expect(context.meshTimeline.map(\.id) == [message.id])
#expect(context.appendedPublicMessages.map(\.message.id) == [message.id])
#expect(context.appendedPublicMessages.first?.conversationID == .mesh)
#expect(!message.isPrivate)
#expect(message.sender == "me")
#expect(context.refreshedChannels.count == 1)
#expect(context.privateChats.isEmpty)
#expect(context.notifyUIChangedCount == 1)
}
@@ -200,7 +203,7 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
#expect(context.privateChats.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(context.appendedPublicMessages.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
}
@@ -42,16 +42,13 @@ private final class MockChatNostrContext: ChatNostrContext {
// Public timeline & pipeline
var messages: [BitchatMessage] = []
private(set) var pipelineResetCount = 0
private(set) var pipelineChannelUpdates: [ChannelID] = []
private(set) var pipelineFlushCount = 0
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var publicSystemMessages: [String] = []
var pendingGeohashSystemMessages: [String] = []
private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = []
private(set) var synchronizedGeohashes: [String] = []
func resetPublicMessagePipeline() { pipelineResetCount += 1 }
func updatePublicMessagePipelineChannel(_ channel: ChannelID) { pipelineChannelUpdates.append(channel) }
func flushPublicMessagePipeline() { pipelineFlushCount += 1 }
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) }
@@ -68,8 +65,6 @@ private final class MockChatNostrContext: ChatNostrContext {
return true
}
func synchronizePublicConversationStore(forGeohash geohash: String) { synchronizedGeohashes.append(geohash) }
// Inbound public messages
private(set) var handledPublicMessages: [BitchatMessage] = []
private(set) var mentionCheckedMessageIDs: [String] = []
@@ -441,9 +436,8 @@ struct ChatNostrCoordinatorContextTests {
coordinator.subscriptions.switchLocationChannel(to: .mesh)
#expect(context.pipelineResetCount == 1)
#expect(context.pipelineFlushCount == 1)
#expect(context.activeChannel == .mesh)
#expect(context.pipelineChannelUpdates == [.mesh])
#expect(context.clearProcessedNostrEventsCount == 1)
#expect(context.refreshedChannels == [.mesh])
#expect(context.refreshTimerStopCount == 1)
@@ -578,7 +572,6 @@ struct GeoPresenceTrackerTests {
await drainMainQueue()
#expect(context.appendedGeohashMessages.isEmpty)
#expect(context.lastGeoNotificationAt["9q8yy"] == recent)
#expect(context.synchronizedGeohashes.isEmpty)
}
@Test @MainActor
@@ -605,7 +598,7 @@ struct GeoPresenceTrackerTests {
#expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy"))
// Cooldown elapsed: the geohash is re-stamped and the append is
// attempted (and rejected as a duplicate, so no store sync either).
// attempted (and rejected as a duplicate, so no notification either).
let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1)
context.lastGeoNotificationAt["9q8yy"] = stale
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
@@ -614,7 +607,6 @@ struct GeoPresenceTrackerTests {
let stamped = try #require(context.lastGeoNotificationAt["9q8yy"])
#expect(stamped > stale)
#expect(context.appendedGeohashMessages.count == 1)
#expect(context.synchronizedGeohashes.isEmpty)
}
@Test @MainActor
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
@@ -661,10 +653,9 @@ struct GeoPresenceTrackerTests {
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first)
await drainMainQueue()
// Sampled message recorded, store synced, and notification posted.
// Sampled message recorded in the store and notification posted.
#expect(context.appendedGeohashMessages.map(\.message.id) == [first.id])
#expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4)))
#expect(context.synchronizedGeohashes == ["u4pruyd"])
#expect(context.geohashActivityNotifications.count == 1)
#expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd")
#expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash")
@@ -49,21 +49,19 @@ private final class MockChatOutgoingContext: ChatOutgoingContext {
}
// Public timeline (local echo)
private(set) var appendedTimelineMessages: [(message: BitchatMessage, channel: ChannelID)] = []
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var trimMessagesIfNeededCount = 0
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
private(set) var systemMessages: [String] = []
func parseMentions(from content: String) -> [String] {
content.contains("@bob") ? ["bob"] : []
}
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
appendedTimelineMessages.append((message, channel))
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
appendedPublicMessages.append((message, conversationID))
return true
}
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
func trimMessagesIfNeeded() { trimMessagesIfNeededCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Content dedup
@@ -129,7 +127,7 @@ struct ChatOutgoingCoordinatorContextTests {
await drainMainActorTasks()
#expect(context.handledCommands == ["/who all"])
#expect(context.appendedTimelineMessages.isEmpty)
#expect(context.appendedPublicMessages.isEmpty)
#expect(context.sentMeshMessages.isEmpty)
}
@@ -153,7 +151,7 @@ struct ChatOutgoingCoordinatorContextTests {
coordinator.sendMessage("dropped")
await drainMainActorTasks()
#expect(context.sentPrivateMessages.count == 1)
#expect(context.appendedTimelineMessages.isEmpty)
#expect(context.appendedPublicMessages.isEmpty)
}
@Test @MainActor
@@ -165,16 +163,14 @@ struct ChatOutgoingCoordinatorContextTests {
await drainMainActorTasks()
// Local echo uses the trimmed content, own nickname/peer ID, mentions.
#expect(context.appendedTimelineMessages.count == 1)
let echo = context.appendedTimelineMessages[0]
#expect(context.appendedPublicMessages.count == 1)
let echo = context.appendedPublicMessages[0]
#expect(echo.message.content == "hello @bob")
#expect(echo.message.sender == "me")
#expect(echo.message.senderPeerID == context.myPeerID)
#expect(echo.message.mentions == ["bob"])
#expect(echo.channel == .mesh)
#expect(context.refreshedChannels == [.mesh])
#expect(echo.conversationID == .mesh)
#expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"])
#expect(context.trimMessagesIfNeededCount == 1)
// The mesh send carries the original (untrimmed) content and reuses
// the echo's message ID and timestamp; activity is stamped for "mesh".
@@ -200,8 +196,9 @@ struct ChatOutgoingCoordinatorContextTests {
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
// the signed event's ID; the send context targets the same channel.
#expect(context.appendedTimelineMessages.count == 1)
let echo = context.appendedTimelineMessages[0].message
#expect(context.appendedPublicMessages.count == 1)
let echo = context.appendedPublicMessages[0].message
#expect(context.appendedPublicMessages[0].conversationID == .geohash("u4pruydq"))
#expect(echo.sender == "me#2222")
#expect(context.recordedActivityKeys == ["geo:u4pruydq"])
#expect(context.sentGeohashContexts.count == 1)
@@ -215,7 +212,7 @@ struct ChatOutgoingCoordinatorContextTests {
coordinator.sendMessage("doomed")
await drainMainActorTasks()
#expect(context.systemMessages.count == 1)
#expect(context.appendedTimelineMessages.count == 1)
#expect(context.appendedPublicMessages.count == 1)
#expect(context.sentGeohashContexts.count == 1)
}
}
@@ -38,11 +38,34 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
func notifyUIChanged() { notifyUIChangedCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Conversation store intents (mirror `ConversationStore` migrate
// semantics: dedup by ID, timestamp order, unread carried, old chat
// removed) while recording calls for assertions.
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
migratedChats.append((oldPeerID, newPeerID))
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
var destination = privateChats[newPeerID] ?? []
for message in source where !destination.contains(where: { $0.id == message.id }) {
let index = destination.firstIndex(where: { $0.timestamp > message.timestamp }) ?? destination.count
destination.insert(message, at: index)
}
privateChats[newPeerID] = destination
privateChats.removeValue(forKey: oldPeerID)
if unreadPrivateMessages.remove(oldPeerID) != nil {
unreadPrivateMessages.insert(newPeerID)
}
}
// Private chat session lifecycle
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
private(set) var syncedReadReceiptPeers: [PeerID] = []
private(set) var begunChatSessions: [PeerID] = []
private(set) var privateStoreSyncCount = 0
private(set) var selectionStoreSyncCount = 0
private(set) var markedReadPeers: [PeerID] = []
@@ -60,7 +83,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
begunChatSessions.append(peerID)
}
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
@@ -261,7 +283,6 @@ struct ChatPeerIdentityCoordinatorContextTests {
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
#expect(context.begunChatSessions == [peerID])
#expect(context.privateStoreSyncCount == 1)
#expect(context.selectionStoreSyncCount == 1)
#expect(context.markedReadPeers == [peerID])
@@ -27,11 +27,19 @@ private final class MockChatPeerListContext: ChatPeerListContext {
// Connection & chat state
var isConnected = false
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var hasTrackedPrivateChatSelection = false
private(set) var updatePrivateChatPeerIfNeededCount = 0
private(set) var cleanupOldReadReceiptsCount = 0
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func updatePrivateChatPeerIfNeeded() {
updatePrivateChatPeerIfNeededCount += 1
}
@@ -48,6 +48,90 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
notifyUIChangedCount += 1
}
// Conversation store intents (mirror `ConversationStore` semantics:
// ordered insert, dedup by ID, no-downgrade status, unread carry on
// migrate) while recording calls for assertions.
private(set) var upsertedMessages: [(messageID: String, peerID: PeerID)] = []
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else {
privateChats[peerID] = chat
return false
}
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
chat.insert(message, at: index)
privateChats[peerID] = chat
return true
}
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message.id, peerID))
if var chat = privateChats[peerID],
let index = chat.firstIndex(where: { $0.id == message.id }) {
chat[index] = message
privateChats[peerID] = chat
} else {
appendPrivateMessage(message, to: peerID)
}
}
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
guard var chat = privateChats[peerID],
let index = chat.firstIndex(where: { $0.id == messageID }) else {
return false
}
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
return false
}
chat[index].deliveryStatus = status
privateChats[peerID] = chat
return true
}
func markPrivateChatUnread(_ peerID: PeerID) {
unreadPrivateMessages.insert(peerID)
}
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func removePrivateChat(_ peerID: PeerID) {
privateChats.removeValue(forKey: peerID)
unreadPrivateMessages.remove(peerID)
}
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
migratedChats.append((oldPeerID, newPeerID))
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
for message in source {
appendPrivateMessage(message, to: newPeerID)
}
if privateChats[newPeerID] == nil {
privateChats[newPeerID] = []
}
let wasUnread = unreadPrivateMessages.contains(oldPeerID)
privateChats.removeValue(forKey: oldPeerID)
unreadPrivateMessages.remove(oldPeerID)
if wasUnread {
unreadPrivateMessages.insert(newPeerID)
}
}
func privateChatsContainMessage(withID messageID: String) -> Bool {
privateChats.values.contains { chat in
chat.contains { $0.id == messageID }
}
}
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool {
privateChats[peerID]?.contains { $0.id == messageID } == true
}
// Peers & identity
var myPeerID = PeerID(str: "0011223344556677")
var nicknamesByPeerID: [PeerID: String] = [:]
@@ -149,10 +233,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
privateMessageNotifications.append((senderName, message, peerID))
}
// System messages & chat hygiene
// System messages
private(set) var systemMessages: [String] = []
private(set) var meshOnlySystemMessages: [String] = []
private(set) var sanitizedPeerIDs: [PeerID] = []
func addSystemMessage(_ content: String) {
systemMessages.append(content)
@@ -162,10 +245,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
meshOnlySystemMessages.append(content)
}
func sanitizeChat(for peerID: PeerID) {
sanitizedPeerIDs.append(peerID)
}
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
@@ -256,7 +335,9 @@ struct ChatPrivateConversationCoordinatorContextTests {
// A different id appends.
coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID)
#expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"])
#expect(context.sanitizedPeerIDs == [peerID, peerID, peerID])
// Every add went through the store's upsert intent.
#expect(context.upsertedMessages.map(\.peerID) == [peerID, peerID, peerID])
#expect(context.upsertedMessages.map(\.messageID) == ["m1", "m1", "m2"])
#expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID))
#expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID))
@@ -436,7 +517,9 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.clearedFingerprints == [oldPeerID])
#expect(context.selectedPrivateChatPeer == newPeerID)
#expect(context.sanitizedPeerIDs == [newPeerID])
// The wholesale move went through the store's migrate intent.
#expect(context.migratedChats.map(\.from) == [oldPeerID])
#expect(context.migratedChats.map(\.to) == [newPeerID])
#expect(context.notifyUIChangedCount == 1)
}
@@ -25,15 +25,13 @@ import BitFoundation
/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPublicConversationContext: ChatPublicConversationContext {
// Channel & visible timeline state
var messages: [BitchatMessage] = []
// Channel state
var activeChannel: ChannelID = .mesh
var currentGeohash: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
private(set) var isBatchingPublic = false
private(set) var notifyUIChangedCount = 0
private(set) var trimMessagesCount = 0
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
@@ -43,102 +41,87 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
notifyUIChangedCount += 1
}
func trimMessagesIfNeeded() {
trimMessagesCount += 1
}
// Public timeline store
var meshTimeline: [BitchatMessage] = []
var geoTimelines: [String: [BitchatMessage]] = [:]
// Public conversation store (single-writer intents)
var conversations: [ConversationID: [BitchatMessage]] = [:]
private(set) var queuedGeohashSystemMessages: [String] = []
func timelineMessages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh: return meshTimeline
case .location(let channel): return geoTimelines[channel.geohash] ?? []
}
func publicMessages(in conversationID: ConversationID) -> [BitchatMessage] {
conversations[conversationID] ?? []
}
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh: meshTimeline.append(message)
case .location(let channel): geoTimelines[channel.geohash, default: []].append(message)
}
}
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
if geoTimelines[geohash]?.contains(where: { $0.id == message.id }) == true {
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
guard conversations[conversationID]?.contains(where: { $0.id == message.id }) != true else {
return false
}
geoTimelines[geohash, default: []].append(message)
conversations[conversationID, default: []].append(message)
return true
}
func removeTimelineMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for (geohash, timeline) in geoTimelines {
guard let index = timeline.firstIndex(where: { $0.id == id }) else { continue }
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
appendPublicMessage(message, to: .geohash(geohash.lowercased()))
}
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool {
conversations[conversationID]?.contains(where: { $0.id == messageID }) == true
}
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for (conversationID, timeline) in conversations {
guard let index = timeline.firstIndex(where: { $0.id == messageID }) else { continue }
var updated = timeline
let removed = updated.remove(at: index)
geoTimelines[geohash] = updated
conversations[conversationID] = updated
return removed
}
return nil
}
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
geoTimelines[geohash]?.removeAll(where: predicate)
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
conversations[.geohash(geohash.lowercased())]?.removeAll(where: predicate)
}
func clearTimeline(for channel: ChannelID) {
switch channel {
case .mesh: meshTimeline.removeAll()
case .location(let channel): geoTimelines[channel.geohash] = []
}
}
private(set) var clearedConversations: [ConversationID] = []
func timelineGeohashKeys() -> [String] {
Array(geoTimelines.keys)
func clearPublicConversation(_ conversationID: ConversationID) {
clearedConversations.append(conversationID)
conversations[conversationID] = []
}
func queueGeohashSystemMessage(_ content: String) {
queuedGeohashSystemMessages.append(content)
}
// Conversation stores
private(set) var conversationActiveChannels: [ChannelID] = []
private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = []
private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = []
private(set) var privateStoreSyncCount = 0
private(set) var selectionStoreSyncCount = 0
func setConversationActiveChannel(_ channel: ChannelID) {
conversationActiveChannels.append(channel)
}
func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
replacedChannelMessages.append((channelID, messages.map(\.id)))
}
func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
replacedConversationMessages.append((conversationID, messages.map(\.id)))
}
func synchronizePrivateConversationStore() {
privateStoreSyncCount += 1
}
func synchronizeConversationSelectionStore() {
selectionStoreSyncCount += 1
}
// Private chats
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
private(set) var removedPrivateChats: [PeerID] = []
private(set) var cleanedUpFileMessageIDs: [String] = []
func removePrivateChat(_ peerID: PeerID) {
removedPrivateChats.append(peerID)
privateChats.removeValue(forKey: peerID)
unreadPrivateMessages.remove(peerID)
}
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
var removed: BitchatMessage?
for (peerID, chat) in privateChats {
guard let message = chat.first(where: { $0.id == messageID }) else { continue }
removed = removed ?? message
let remaining = chat.filter { $0.id != messageID }
if remaining.isEmpty {
privateChats.removeValue(forKey: peerID)
} else {
privateChats[peerID] = remaining
}
}
return removed
}
func cleanupLocalFile(forMessage message: BitchatMessage) {
cleanedUpFileMessageIDs.append(message.id)
}
@@ -204,7 +187,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
var blockedMessageIDs: Set<String> = []
var rateLimitAllowed = true
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
private(set) var enqueuedMessageIDs: [String] = []
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
var stablePeerIDs: [PeerID: PeerID] = [:]
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
@@ -220,8 +204,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
return rateLimitAllowed
}
func enqueuePublicMessage(_ message: BitchatMessage) {
enqueuedMessageIDs.append(message.id)
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
enqueuedMessages.append((message.id, conversationID))
}
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? {
@@ -291,24 +275,24 @@ private func makePublicMessage(
struct ChatPublicConversationCoordinatorContextTests {
@Test @MainActor
func handlePublicMessage_meshMessage_appendsSyncsStoreAndEnqueues() async {
func handlePublicMessage_meshMessage_enqueuesForBatchedStoreCommit() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh")
coordinator.handlePublicMessage(message)
#expect(context.meshTimeline.map(\.id) == ["mesh-msg-1"])
#expect(context.replacedChannelMessages.count == 1)
#expect(context.replacedChannelMessages.first?.channel == .mesh)
#expect(context.replacedChannelMessages.first?.messageIDs == ["mesh-msg-1"])
// Visible-channel arrival: buffered for the batched pipeline flush
// (which commits to the store), not appended directly.
#expect(context.rateLimitChecks.count == 1)
#expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011")
#expect(context.rateLimitChecks.first?.contentKey == "hello mesh")
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
#expect(context.enqueuedMessages.map(\.messageID) == ["mesh-msg-1"])
#expect(context.enqueuedMessages.first?.conversationID == .mesh)
#expect(context.publicMessages(in: .mesh).isEmpty)
// Already visible in the timeline: stored again, but not re-enqueued.
context.messages = [message]
// Already committed to the store: not re-enqueued.
context.appendPublicMessage(message, to: .mesh)
coordinator.handlePublicMessage(message)
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
}
@@ -322,14 +306,14 @@ struct ChatPublicConversationCoordinatorContextTests {
context.blockedMessageIDs = ["blocked-msg"]
coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg"))
#expect(context.rateLimitChecks.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.enqueuedMessageIDs.isEmpty)
// Rate limited: consulted, then dropped before storage.
context.rateLimitAllowed = false
coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg"))
#expect(context.rateLimitChecks.count == 1)
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.enqueuedMessageIDs.isEmpty)
}
@@ -346,16 +330,15 @@ struct ChatPublicConversationCoordinatorContextTests {
senderPeerID: PeerID(nostr: senderHex)
)
// On mesh channel: stored in the geohash timeline but not enqueued.
// On mesh channel: a background-channel arrival lands in the geohash
// conversation immediately, with no pipeline batching.
context.activeChannel = .mesh
coordinator.handlePublicMessage(geoMessage)
#expect(context.geoTimelines[geohash]?.map(\.id) == ["geo-msg-1"])
#expect(context.replacedConversationMessages.count == 1)
#expect(context.replacedConversationMessages.first?.conversation == .geohash(geohash))
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .geohash(geohash)).map(\.id) == ["geo-msg-1"])
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.enqueuedMessageIDs.isEmpty)
// On the matching location channel: enqueued for display.
// On the matching location channel: enqueued for the batched flush.
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
let second = makePublicMessage(
id: "geo-msg-2",
@@ -363,7 +346,8 @@ struct ChatPublicConversationCoordinatorContextTests {
senderPeerID: PeerID(nostr: senderHex)
)
coordinator.handlePublicMessage(second)
#expect(context.enqueuedMessageIDs == ["geo-msg-2"])
#expect(context.enqueuedMessages.map(\.messageID) == ["geo-msg-2"])
#expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash))
}
@Test @MainActor
@@ -378,8 +362,7 @@ struct ChatPublicConversationCoordinatorContextTests {
context.currentGeohash = geohash
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
context.geoTimelines[geohash] = [geoMessage]
context.messages = [geoMessage]
context.conversations[.geohash(geohash)] = [geoMessage]
context.nostrKeyMapping = [senderPeerID: hex, convKey: hex]
context.privateChats[convKey] = [geoMessage]
context.unreadPrivateMessages = [convKey]
@@ -388,13 +371,14 @@ struct ChatPublicConversationCoordinatorContextTests {
#expect(context.blockedNostrPubkeys.contains(hex))
#expect(context.removedGeoParticipants == [hex])
#expect(context.geoTimelines[geohash]?.isEmpty == true)
#expect(context.privateChats[convKey] == nil)
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.nostrKeyMapping.isEmpty)
// The blocked user's visible message is gone; a system notice was added.
#expect(!context.messages.contains(where: { $0.id == "geo-bad-1" }))
#expect(context.messages.last?.sender == "system")
// The blocked user's message is purged from the geohash conversation
// (the visible timeline is the same conversation now); a system
// notice was appended to the active conversation.
#expect(!context.publicMessages(in: .geohash(geohash)).contains(where: { $0.id == "geo-bad-1" }))
#expect(context.publicMessages(in: .geohash(geohash)).last?.sender == "system")
coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
#expect(!context.blockedNostrPubkeys.contains(hex))
@@ -406,36 +390,27 @@ struct ChatPublicConversationCoordinatorContextTests {
let coordinator = ChatPublicConversationCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
let message = makePublicMessage(id: "doomed-msg")
context.messages = [message]
context.meshTimeline = [message]
context.conversations[.mesh] = [message]
context.privateChats[peerID] = [message]
coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true)
#expect(context.messages.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.privateChats[peerID] == nil)
#expect(context.cleanedUpFileMessageIDs == ["doomed-msg"])
#expect(context.notifyUIChangedCount == 1)
// Timeline removal triggers a full conversation-store resync.
#expect(context.replacedChannelMessages.contains(where: { $0.channel == .mesh && $0.messageIDs.isEmpty }))
}
@Test @MainActor
func addPublicSystemMessage_appendsRefreshesAndRecordsContentKey() async {
func addPublicSystemMessage_appendsToActiveConversationAndRecordsContentKey() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
coordinator.addPublicSystemMessage("Tor Ready")
#expect(context.meshTimeline.count == 1)
#expect(context.meshTimeline.first?.sender == "system")
// refreshVisibleMessages mirrors the timeline into the visible list.
#expect(context.messages.map(\.id) == context.meshTimeline.map(\.id))
#expect(context.publicMessages(in: .mesh).count == 1)
#expect(context.publicMessages(in: .mesh).first?.sender == "system")
#expect(context.recordedContentKeys.map(\.key) == ["tor ready"])
#expect(context.trimMessagesCount == 1)
#expect(context.notifyUIChangedCount == 1)
#expect(context.conversationActiveChannels == [.mesh])
// On mesh, geohash-only system messages are queued for the next geo visit.
coordinator.addGeohashOnlySystemMessage("geo notice")
@@ -459,22 +434,20 @@ struct ChatPublicConversationCoordinatorContextTests {
let coordinator = ChatPublicConversationCoordinator(context: context)
let pipeline = PublicMessagePipeline()
let message = makePublicMessage(id: "pipeline-msg")
context.messages = [message]
context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42)
#expect(coordinator.pipelineCurrentMessages(pipeline).map(\.id) == ["pipeline-msg"])
#expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello")
#expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42))
coordinator.pipeline(pipeline, setMessages: [])
#expect(context.messages.isEmpty)
// Commit lands in the store via the append intent; a duplicate ID
// reports `false` (the store's dedup contract).
#expect(coordinator.pipeline(pipeline, commit: message, to: .mesh))
#expect(context.publicMessages(in: .mesh).map(\.id) == ["pipeline-msg"])
#expect(!coordinator.pipeline(pipeline, commit: message, to: .mesh))
coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7))
#expect(context.recordedContentKeys.map(\.key) == ["key-2"])
coordinator.pipelineTrimMessages(pipeline)
#expect(context.trimMessagesCount == 1)
coordinator.pipelinePrewarmMessage(pipeline, message: message)
#expect(context.prewarmedMessageIDs == ["pipeline-msg"])
@@ -28,11 +28,39 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
private(set) var unmarkedReadReceiptBatches: [[String]] = []
private(set) var notifyUIChangedCount = 0
// Conversation store intents (mirror `ConversationStore` semantics)
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else { return false }
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
chat.insert(message, at: index)
privateChats[peerID] = chat
return true
}
func removePrivateChat(_ peerID: PeerID) {
privateChats.removeValue(forKey: peerID)
unreadPrivateMessages.remove(peerID)
}
func markPrivateChatUnread(_ peerID: PeerID) {
unreadPrivateMessages.insert(peerID)
}
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func unmarkReadReceiptsSent(_ ids: [String]) {
unmarkedReadReceiptBatches.append(ids)
}
@@ -54,7 +54,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .read(by: "Peer", at: Date())
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
// Action: try to downgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
@@ -85,7 +85,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
// Action: upgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
@@ -114,7 +114,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent)
@@ -140,7 +140,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
// Action: upgrade to .read
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
@@ -173,7 +173,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
// Action: receive read receipt
let receipt = ReadReceipt(
@@ -207,7 +207,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
viewModel.isStartupPhase = false
@@ -233,7 +233,7 @@ struct ChatViewModelDeliveryStatusTests {
isPrivate: false,
deliveryStatus: .sending
)
viewModel.messages.append(message)
viewModel.seedPublicMessages([message])
// Action: update to .sent
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
@@ -253,7 +253,7 @@ struct ChatViewModelDeliveryStatusTests {
let firstPeerID = PeerID(str: "0102030405060708")
let secondPeerID = PeerID(str: "1112131415161718")
viewModel.messages = [
viewModel.seedPublicMessages([
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
@@ -264,8 +264,8 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
]
viewModel.privateChats[firstPeerID] = [
])
viewModel.seedPrivateChat([
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
@@ -277,8 +277,8 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
]
viewModel.privateChats[secondPeerID] = [
], for: firstPeerID)
viewModel.seedPrivateChat([
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
@@ -290,7 +290,7 @@ struct ChatViewModelDeliveryStatusTests {
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
]
], for: secondPeerID)
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
messageID,
@@ -304,7 +304,7 @@ struct ChatViewModelDeliveryStatusTests {
}
@Test @MainActor
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
func deliveryStatus_survivesPrivateChatReorder() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "reordered-msg-1"
@@ -331,10 +331,15 @@ struct ChatViewModelDeliveryStatusTests {
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [targetMessage, olderMessage]
viewModel.seedPrivateChat([targetMessage], for: peerID)
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
viewModel.privateChats[peerID] = [olderMessage, targetMessage]
// A late arrival with an older timestamp is inserted before the
// target by the store, shifting its position; the store's ID-keyed
// indexes must keep the target updatable.
viewModel.seedPrivateChat([olderMessage], for: peerID)
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID])
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
messageID,
status: .read(by: "Peer", at: Date())
@@ -378,16 +383,31 @@ struct ChatViewModelDeliveryStatusTests {
// MARK: - Mock Delivery Context
/// Lightweight stand-in for `ChatDeliveryContext` proving that
/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`.
/// `ChatDeliveryCoordinator` is testable without constructing a
/// `ChatViewModel`: the delivery surface forwards to a real
/// `ConversationStore` (the coordinator is a thin mapper over store
/// intents), and assertions read store state.
@MainActor
private final class MockChatDeliveryContext: ChatDeliveryContext {
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let store = ConversationStore()
var sentReadReceipts: Set<String> = []
var isStartupPhase = false
private(set) var notifyUIChangedCount = 0
private(set) var markedDeliveredMessageIDs: [String] = []
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
store.setDeliveryStatus(status, forMessageID: messageID)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
store.deliveryStatus(forMessageID: messageID)
}
func privateMessageIDs() -> Set<String> {
store.directMessageIDs()
}
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
@@ -404,12 +424,16 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
}
@MainActor
private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage {
private func makePrivateMessage(
id: String,
status: DeliveryStatus,
timestamp: Date = Date()
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "me",
content: "Test message",
timestamp: Date(),
timestamp: timestamp,
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
@@ -418,10 +442,28 @@ private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMe
)
}
@MainActor
private func makePublicMessage(
id: String,
status: DeliveryStatus,
timestamp: Date = Date()
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "me",
content: "Public message",
timestamp: timestamp,
isRelay: false,
isPrivate: false,
deliveryStatus: status
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext`
/// the exemplar for the narrow-dependency coordinator pattern.
/// the exemplar for the narrow-dependency coordinator pattern. State is
/// seeded into and asserted against the mock's `ConversationStore`.
struct ChatDeliveryCoordinatorContextTests {
@Test @MainActor
@@ -430,7 +472,7 @@ struct ChatDeliveryCoordinatorContextTests {
let coordinator = ChatDeliveryCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
let messageID = "mock-msg-1"
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .sent)]
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(peerID))
let didUpdate = coordinator.updateMessageDeliveryStatus(
messageID,
@@ -438,7 +480,7 @@ struct ChatDeliveryCoordinatorContextTests {
)
#expect(didUpdate)
#expect(isDelivered(context.privateChats[peerID]?.first?.deliveryStatus))
#expect(isDelivered(context.store.conversation(for: .directPeer(peerID)).message(withID: messageID)?.deliveryStatus))
#expect(context.notifyUIChangedCount == 1)
#expect(context.markedDeliveredMessageIDs == [messageID])
}
@@ -449,13 +491,16 @@ struct ChatDeliveryCoordinatorContextTests {
let coordinator = ChatDeliveryCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
let messageID = "mock-msg-2"
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date()))]
context.store.append(
makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date())),
to: .directPeer(peerID)
)
coordinator.didReceiveReadReceipt(
ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer")
)
#expect(isRead(context.privateChats[peerID]?.first?.deliveryStatus))
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
#expect(context.notifyUIChangedCount == 1)
#expect(context.markedDeliveredMessageIDs == [messageID])
}
@@ -464,22 +509,12 @@ struct ChatDeliveryCoordinatorContextTests {
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
context.messages = [
BitchatMessage(
id: "public-mock-1",
sender: "me",
content: "Public message",
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sending
)
]
context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh)
// .sent is not a confirmed receipt must not reach markMessageDelivered.
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
#expect(didUpdate)
#expect(isSent(context.messages.first?.deliveryStatus))
#expect(isSent(context.store.conversation(for: .mesh).message(withID: "public-mock-1")?.deliveryStatus))
#expect(context.markedDeliveredMessageIDs.isEmpty)
#expect(context.notifyUIChangedCount == 1)
@@ -489,57 +524,98 @@ struct ChatDeliveryCoordinatorContextTests {
#expect(context.notifyUIChangedCount == 1)
}
// The old positional `messageLocationIndex` could go stale when a late
// arrival was inserted mid-array (count grew but indexed locations
// shifted). The store's per-conversation ID index is reindexed inside the
// same mutation, so staleness is structurally impossible these tests
// pin the equivalent behavior through the new path: after out-of-order
// insertion, updates keyed by ID still land on the right messages.
@Test @MainActor
func middleInsertedMessage_isFoundAfterIndexWasBuilt() async {
func middleInsertedMessage_isStillUpdatableByID() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let makePublic = { (id: String) in
BitchatMessage(
id: id,
sender: "me",
content: "Public message",
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sending
)
}
context.messages = [makePublic("public-a"), makePublic("public-b")]
context.store.append(
makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
to: .mesh
)
context.store.append(
makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
to: .mesh
)
// Build the incremental index.
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
// Out-of-order arrival: PublicMessagePipeline inserts by timestamp,
// so the count grows while the tail ID stays the same.
context.messages.insert(makePublic("public-mid"), at: 1)
// Out-of-order arrival: the store inserts by timestamp, shifting the
// tail's position.
context.store.append(
makePublicMessage(id: "public-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)),
to: .mesh
)
let mesh = context.store.conversation(for: .mesh)
#expect(mesh.messages.map(\.id) == ["public-a", "public-mid", "public-b"])
// The inserted message must be locatable, and the shifted tail must
// not be updated through a stale index entry.
// Both the inserted message and the shifted tail stay updatable.
#expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent))
#expect(isSent(context.messages[1].deliveryStatus))
#expect(isSent(mesh.message(withID: "public-mid")?.deliveryStatus))
#expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent))
#expect(isSent(context.messages[2].deliveryStatus))
#expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus))
}
@Test @MainActor
func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async {
func middleInsertedPrivateMessage_isStillUpdatableByID() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
context.privateChats[peerID] = [
makePrivateMessage(id: "pm-a", status: .sending),
makePrivateMessage(id: "pm-b", status: .sending)
]
let conversationID = ConversationID.directPeer(peerID)
context.store.append(
makePrivateMessage(id: "pm-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
to: conversationID
)
context.store.append(
makePrivateMessage(id: "pm-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
to: conversationID
)
#expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent))
// Timestamp re-sort (sanitizeChat) can place a late arrival mid-array.
context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1)
// A late arrival with an older timestamp lands mid-array.
context.store.append(
makePrivateMessage(id: "pm-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)),
to: conversationID
)
let chat = context.store.conversation(for: conversationID)
#expect(chat.messages.map(\.id) == ["pm-a", "pm-mid", "pm-b"])
#expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent))
#expect(isSent(context.privateChats[peerID]?[1].deliveryStatus))
#expect(isSent(chat.message(withID: "pm-mid")?.deliveryStatus))
#expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent))
#expect(isSent(context.privateChats[peerID]?[2].deliveryStatus))
#expect(isSent(chat.message(withID: "pm-b")?.deliveryStatus))
}
@Test @MainActor
func mirroredPrivateCopies_bothReceiveDeliveryUpdate() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let stablePeerID = PeerID(str: String(repeating: "ab", count: 32))
let ephemeralPeerID = PeerID(str: "0102030405060708")
let messageID = "mirrored-mock-1"
// Step 2's keying mirrors a private message into both the stable-key
// and ephemeral-peer conversations (distinct copies here to prove
// per-conversation application, not shared-reference aliasing).
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(stablePeerID))
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(ephemeralPeerID))
let didUpdate = coordinator.updateMessageDeliveryStatus(
messageID,
status: .delivered(to: "Peer", at: Date())
)
#expect(didUpdate)
#expect(isDelivered(context.store.conversation(for: .directPeer(stablePeerID)).message(withID: messageID)?.deliveryStatus))
#expect(isDelivered(context.store.conversation(for: .directPeer(ephemeralPeerID)).message(withID: messageID)?.deliveryStatus))
#expect(context.markedDeliveredMessageIDs == [messageID])
}
@Test @MainActor
@@ -547,7 +623,7 @@ struct ChatDeliveryCoordinatorContextTests {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
context.privateChats[peerID] = [makePrivateMessage(id: "keep-receipt", status: .sent)]
context.store.append(makePrivateMessage(id: "keep-receipt", status: .sent), to: .directPeer(peerID))
context.sentReadReceipts = ["keep-receipt", "drop-receipt"]
// Startup phase: cleanup must be a no-op.
@@ -177,7 +177,7 @@ struct ChatViewModelPrivateChatExtensionTests {
isPrivate: true,
senderPeerID: oldPeerID
)
viewModel.privateChats[oldPeerID] = [oldMessage]
viewModel.seedPrivateChat([oldMessage], for: oldPeerID)
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
// Setup new peer fingerprint
@@ -444,7 +444,7 @@ struct ChatViewModelNostrExtensionTests {
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-delivered"
viewModel.privateChats[convKey] = [
viewModel.seedPrivateChat([
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
@@ -456,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
], for: convKey)
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
@@ -482,7 +482,7 @@ struct ChatViewModelNostrExtensionTests {
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-read"
viewModel.privateChats[convKey] = [
viewModel.seedPrivateChat([
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
@@ -494,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .delivered(to: "Friend", at: Date())
)
]
], for: convKey)
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
@@ -578,7 +578,7 @@ struct ChatViewModelNostrExtensionTests {
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "gift-delivered"
viewModel.privateChats[convKey] = [
viewModel.seedPrivateChat([
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
@@ -590,7 +590,7 @@ struct ChatViewModelNostrExtensionTests {
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
], for: convKey)
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
@@ -1095,7 +1095,7 @@ struct ChatViewModelMediaTransferTests {
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sending
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
viewModel.cancelMediaSend(messageID: message.id)
@@ -1124,7 +1124,7 @@ struct ChatViewModelMediaTransferTests {
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
viewModel.deleteMediaMessage(messageID: message.id)
@@ -138,7 +138,7 @@ struct ChatViewModelRefactoringTests {
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout
)
+80 -72
View File
@@ -136,7 +136,7 @@ struct ChatViewModelIdentityTests {
senderPeerID: oldPeerID,
mentions: nil
)
viewModel.privateChats[oldPeerID] = [existingMessage]
viewModel.seedPrivateChat([existingMessage], for: oldPeerID)
viewModel.startPrivateChat(with: oldPeerID)
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
@@ -345,8 +345,8 @@ struct ChatViewModelServiceLifecycleTests {
mentions: nil
)
viewModel.privateChats[peerID] = [message]
viewModel.unreadPrivateMessages.insert(peerID)
viewModel.seedPrivateChat([message], for: peerID)
viewModel.markPrivateChatUnread(peerID)
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleDidBecomeActive()
@@ -438,7 +438,7 @@ struct ChatViewModelReceivingTests {
)
let found = await TestHelpers.waitUntil({
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
#expect(found)
@@ -497,7 +497,7 @@ struct ChatViewModelNoisePayloadTests {
mentions: nil,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
viewModel.didReceiveNoisePayload(
from: peerID,
@@ -535,7 +535,7 @@ struct ChatViewModelNoisePayloadTests {
mentions: nil,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.seedPrivateChat([message], for: peerID)
viewModel.didReceiveNoisePayload(
from: peerID,
@@ -553,10 +553,7 @@ struct ChatViewModelNoisePayloadTests {
}, timeout: TestConstants.defaultTimeout)
let conversationStoreUpdated = await TestHelpers.waitUntil({
let messages = viewModel.conversationStore.directMessages(
for: peerID,
identityResolver: viewModel.identityResolver
)
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
guard let status = messages.first?.deliveryStatus else { return false }
if case .read = status {
return true
@@ -709,10 +706,12 @@ struct ChatViewModelPublicConversationTests {
let (viewModel, _) = makeTestableViewModel()
viewModel.addPublicSystemMessage("system refresh test")
viewModel.messages.removeAll()
viewModel.refreshVisibleMessages(from: .mesh)
// The system message lives in the mesh conversation itself, so the
// derived `messages` view still surfaces it after a refresh.
#expect(viewModel.messages.last?.content == "system refresh test")
#expect(viewModel.publicMessages(for: .mesh).last?.content == "system refresh test")
}
@Test @MainActor
@@ -726,6 +725,18 @@ struct ChatViewModelPublicConversationTests {
viewModel.refreshVisibleMessages(from: .mesh)
#expect(viewModel.messages.isEmpty)
#expect(viewModel.publicMessages(for: .mesh).isEmpty)
}
@Test @MainActor
func queuedGeohashSystemMessages_drainOnce() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.queueGeohashSystemMessage("first")
viewModel.queueGeohashSystemMessage("second")
#expect(viewModel.drainPendingGeohashSystemMessages() == ["first", "second"])
#expect(viewModel.drainPendingGeohashSystemMessages().isEmpty)
}
}
@@ -771,7 +782,7 @@ struct ChatViewModelPeerTests {
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
let (viewModel, _) = makeTestableViewModel()
let stalePeer = PeerID(str: "00000000000000a2")
viewModel.unreadPrivateMessages = [stalePeer]
viewModel.markPrivateChatUnread(stalePeer)
viewModel.didUpdatePeerList([])
@@ -798,8 +809,8 @@ struct ChatViewModelPeerTests {
senderPeerID: stablePeer,
mentions: nil
)
viewModel.privateChats[stablePeer] = [message]
viewModel.unreadPrivateMessages = [stablePeer]
viewModel.seedPrivateChat([message], for: stablePeer)
viewModel.markPrivateChatUnread(stablePeer)
viewModel.didUpdatePeerList([])
try? await Task.sleep(nanoseconds: 100_000_000)
@@ -876,33 +887,32 @@ struct ChatViewModelPrivateChatSelectionTests {
let older = Date().addingTimeInterval(-120)
let newer = Date().addingTimeInterval(-30)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.unreadPrivateMessages = [peerA, peerB]
viewModel.seedPrivateChat([
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
], for: peerA)
viewModel.seedPrivateChat([
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
], for: peerB)
viewModel.markPrivateChatUnread(peerA)
viewModel.markPrivateChatUnread(peerB)
viewModel.openMostRelevantPrivateChat()
@@ -918,32 +928,30 @@ struct ChatViewModelPrivateChatSelectionTests {
let older = Date().addingTimeInterval(-200)
let newer = Date().addingTimeInterval(-20)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.seedPrivateChat([
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
], for: peerA)
viewModel.seedPrivateChat([
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
], for: peerB)
viewModel.openMostRelevantPrivateChat()
@@ -1002,7 +1010,7 @@ struct ChatViewModelPanicTests {
// Set up some state
transport.connectedPeers.insert(PeerID(str: "PEER1"))
viewModel.messages = [
viewModel.seedPublicMessages([
BitchatMessage(
id: "panic-1",
sender: "Tester",
@@ -1010,8 +1018,8 @@ struct ChatViewModelPanicTests {
timestamp: Date(),
isRelay: false
)
]
viewModel.privateChats[PeerID(str: "PEER1")] = [
])
viewModel.seedPrivateChat([
BitchatMessage(
id: "pm-1",
sender: "Peer",
@@ -1022,8 +1030,8 @@ struct ChatViewModelPanicTests {
recipientNickname: "Me",
senderPeerID: PeerID(str: "PEER1")
)
]
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
], for: PeerID(str: "PEER1"))
viewModel.markPrivateChatUnread(PeerID(str: "PEER1"))
viewModel.panicClearAllData()
+6
View File
@@ -423,6 +423,12 @@ private final class MockCommandContextProvider: CommandContextProvider {
clearCurrentPublicTimelineCallCount += 1
}
private(set) var clearedPrivateChats: [PeerID] = []
func clearPrivateChat(_ peerID: PeerID) {
clearedPrivateChats.append(peerID)
privateChats[peerID] = []
}
func sendPublicRaw(_ content: String) {
sentPublicRawMessages.append(content)
}
+757
View File
@@ -0,0 +1,757 @@
//
// 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)")
))
}
@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)
}
// MARK: - Public timelines (mesh/geohash, ex-PublicTimelineStore behavior)
@Test("geohash conversations are separated by geohash and from mesh")
@MainActor
func geohashConversationSeparation() {
let store = ConversationStore()
store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh)
store.append(makeMessage(id: "geo-a-1", timestamp: 2), to: .geohash("u4pruyd"))
store.append(makeMessage(id: "geo-b-1", timestamp: 3), to: .geohash("9q8yy"))
#expect(store.conversation(for: .mesh).messages.map(\.id) == ["mesh-1"])
#expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["geo-a-1"])
#expect(store.conversation(for: .geohash("9q8yy")).messages.map(\.id) == ["geo-b-1"])
}
@Test("geohash append dedups by ID and reports duplicates")
@MainActor
func geohashAppendIfAbsentContract() {
let store = ConversationStore()
let message = makeMessage(id: "geo-1", timestamp: 1)
#expect(store.append(message, to: .geohash("u4pruyd")))
#expect(!store.append(message, to: .geohash("u4pruyd")))
// The same ID is still fresh in a different geohash.
#expect(store.append(message, to: .geohash("9q8yy")))
}
@Test("removePublicMessage searches mesh and geohash conversations only")
@MainActor
func removePublicMessageSearchesPublicConversations() {
let store = ConversationStore()
let direct = makeDirectConversationID("aa")
store.append(makeMessage(id: "mesh-1", timestamp: 1), to: .mesh)
store.append(makeMessage(id: "geo-1", timestamp: 2), to: .geohash("u4pruyd"))
store.append(makeMessage(id: "dm-1", timestamp: 3, isPrivate: true), to: direct)
#expect(store.removePublicMessage(withID: "geo-1")?.id == "geo-1")
#expect(store.conversation(for: .geohash("u4pruyd")).messages.isEmpty)
#expect(store.removePublicMessage(withID: "mesh-1")?.id == "mesh-1")
#expect(store.conversation(for: .mesh).messages.isEmpty)
// Direct conversations are never touched.
#expect(store.removePublicMessage(withID: "dm-1") == nil)
#expect(store.conversation(for: direct).messages.map(\.id) == ["dm-1"])
}
@Test("removeMessages(from:where:) purges matches and emits per removal")
@MainActor
func removeMessagesByPredicate() {
let store = ConversationStore()
let id = ConversationID.geohash("u4pruyd")
store.append(makeMessage(id: "keep-1", timestamp: 1), to: id)
store.append(makeMessage(id: "drop-1", timestamp: 2, content: "purge me"), to: id)
store.append(makeMessage(id: "drop-2", timestamp: 3, content: "purge me"), to: id)
store.append(makeMessage(id: "keep-2", timestamp: 4), to: id)
var removedIDs: [String] = []
var cancellables = Set<AnyCancellable>()
store.changes
.sink { change in
if case .messageRemoved(_, let messageID) = change {
removedIDs.append(messageID)
}
}
.store(in: &cancellables)
store.removeMessages(from: id, where: { $0.content == "purge me" })
#expect(store.conversation(for: id).messages.map(\.id) == ["keep-1", "keep-2"])
#expect(removedIDs == ["drop-1", "drop-2"])
// The ID index survives the purge: dedup and removal still work.
#expect(!store.append(makeMessage(id: "keep-2", timestamp: 4), to: id))
#expect(store.removeMessage(withID: "keep-1", from: id) != nil)
#expect(store.conversation(for: id).messages.map(\.id) == ["keep-2"])
}
@Test("trimmed public message IDs can return after falling off the cap")
@MainActor
func trimmedMessageIDsCanReturn() {
let store = ConversationStore()
let id = ConversationID.geohash("u4pruyd")
let conversation = store.conversation(for: id)
let first = makeMessage(id: "one", timestamp: 1)
store.append(first, to: id)
for index in 0..<conversation.cap {
store.append(makeMessage(id: "filler-\(index)", timestamp: 2 + TimeInterval(index)), to: id)
}
// "one" was trimmed by the cap, so its ID is free again.
#expect(!conversation.containsMessage(withID: "one"))
#expect(store.append(makeMessage(id: "one", timestamp: 2000), to: id))
}
// MARK: - Store-level message-ID conversation map (ID-only delivery)
@Test("ID map tracks append, upsert, and removal")
@MainActor
func messageIDMapTracksAppendAndRemoval() {
let store = ConversationStore()
let direct = makeDirectConversationID("aa")
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
#expect(store.conversationIDs(forMessageID: "m1") == [.mesh])
// Upsert-as-append registers; upsert-in-place does not duplicate.
store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true), in: direct)
store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: direct)
#expect(store.conversationIDs(forMessageID: "d1") == [direct])
store.removeMessage(withID: "m1", from: .mesh)
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
store.removeMessages(from: direct, where: { $0.id == "d1" })
#expect(store.conversationIDs(forMessageID: "d1").isEmpty)
}
@Test("ID map handles multi-conversation membership (mirrored private copies)")
@MainActor
func messageIDMapHandlesMirroredCopies() {
let store = ConversationStore()
let stable = makeDirectConversationID("stable")
let ephemeral = makeDirectConversationID("ephemeral")
// Step 2's keying mirrors one private message into the stable-key
// AND ephemeral-peer conversations (distinct copies here to prove
// per-conversation bookkeeping).
store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: stable)
store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: ephemeral)
#expect(store.conversationIDs(forMessageID: "dm-1") == [stable, ephemeral])
// An ID-only delivery update reaches BOTH copies.
#expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
for id in [stable, ephemeral] {
guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else {
Issue.record("expected .delivered in \(id)")
return
}
}
// Removing one copy keeps the other resolvable.
store.removeMessage(withID: "dm-1", from: ephemeral)
#expect(store.conversationIDs(forMessageID: "dm-1") == [stable])
}
@Test("ID-only setDeliveryStatus enforces no-downgrade and unknown IDs")
@MainActor
func idOnlyDeliveryStatusRules() {
let store = ConversationStore()
let direct = makeDirectConversationID("aa")
store.append(
makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .read(by: "bob", at: Date())),
to: direct
)
// Unknown message.
#expect(!store.setDeliveryStatus(.sent, forMessageID: "missing"))
#expect(store.deliveryStatus(forMessageID: "missing") == nil)
// Downgrade from .read is refused; status is readable by ID alone.
#expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
guard case .read = store.deliveryStatus(forMessageID: "dm-1") else {
Issue.record("expected .read to survive the downgrade attempt")
return
}
}
@Test("ID map follows migration between conversations")
@MainActor
func messageIDMapFollowsMigration() {
let store = ConversationStore()
let source = makeDirectConversationID("ephemeral")
let destination = makeDirectConversationID("stable")
store.append(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true), to: source)
store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: source)
// Already present in the destination: migration dedups, and the map
// must not retain a stale source membership.
store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: destination)
store.migrateConversation(from: source, to: destination)
#expect(store.conversationIDs(forMessageID: "dm-1") == [destination])
#expect(store.conversationIDs(forMessageID: "dm-2") == [destination])
// Delivery updates keep flowing after the handoff.
#expect(store.setDeliveryStatus(.sent, forMessageID: "dm-1"))
}
@Test("ID map drops trimmed messages")
@MainActor
func messageIDMapDropsTrimmedMessages() {
let store = ConversationStore()
let id = ConversationID.geohash("u4pruyd")
let conversation = store.conversation(for: id)
store.append(makeMessage(id: "first", timestamp: 1), to: id)
for index in 0..<conversation.cap {
store.append(makeMessage(id: "filler-\(index)", timestamp: 2 + TimeInterval(index)), to: id)
}
// "first" fell off the cap: the map must forget it.
#expect(store.conversationIDs(forMessageID: "first").isEmpty)
#expect(!store.setDeliveryStatus(.sent, forMessageID: "first"))
// Survivors stay resolvable.
#expect(store.conversationIDs(forMessageID: "filler-0") == [id])
}
@Test("ID map is emptied by clear, removeConversation, and clearAll")
@MainActor
func messageIDMapClearedWithConversations() {
let store = ConversationStore()
let direct = makeDirectConversationID("aa")
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
store.append(makeMessage(id: "d1", timestamp: 1, isPrivate: true), to: direct)
store.clear(.mesh)
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
#expect(store.conversationIDs(forMessageID: "d1") == [direct])
store.removeConversation(direct)
#expect(store.conversationIDs(forMessageID: "d1").isEmpty)
store.append(makeMessage(id: "m2", timestamp: 2), to: .mesh)
store.clearAll()
#expect(store.conversationIDs(forMessageID: "m2").isEmpty)
}
@Test("shared message instance across conversations stays consistent")
@MainActor
func sharedInstanceMirroredCopiesStayConsistent() {
let store = ConversationStore()
let stable = makeDirectConversationID("stable")
let ephemeral = makeDirectConversationID("ephemeral")
// The production mirroring path upserts the SAME BitchatMessage
// instance (reference type) into both conversations.
let message = makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent)
store.upsertByID(message, in: stable)
store.upsertByID(message, in: ephemeral)
#expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
for id in [stable, ephemeral] {
guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else {
Issue.record("expected .delivered in \(id)")
return
}
}
}
@Test("mirrored conversations both republish and emit on a shared-instance status change")
@MainActor
func sharedInstanceMirroredCopiesBothRepublishOnStatusChange() {
let store = ConversationStore()
let stable = makeDirectConversationID("stable")
let ephemeral = makeDirectConversationID("ephemeral")
let message = makeMessage(id: "dm-2", timestamp: 1, isPrivate: true, deliveryStatus: .sent)
store.upsertByID(message, in: stable)
store.upsertByID(message, in: ephemeral)
var cancellables = Set<AnyCancellable>()
var publishedIDs: [ConversationID] = []
for id in [stable, ephemeral] {
store.conversation(for: id).objectWillChange
.sink { publishedIDs.append(id) }
.store(in: &cancellables)
}
var statusChangedIDs: [ConversationID] = []
store.changes
.sink { change in
if case .statusChanged(let id, "dm-2", _) = change { statusChangedIDs.append(id) }
}
.store(in: &cancellables)
let read = DeliveryStatus.read(by: "bob", at: Date())
#expect(store.setDeliveryStatus(read, forMessageID: "dm-2"))
// The shared instance is mutated once, but a view observing EITHER
// conversation must re-render, and both emit a change event.
#expect(Set(publishedIDs) == Set([stable, ephemeral]))
#expect(Set(statusChangedIDs) == Set([stable, ephemeral]))
// A duplicate ack applies nowhere and must publish nothing.
publishedIDs.removeAll()
statusChangedIDs.removeAll()
#expect(!store.setDeliveryStatus(read, forMessageID: "dm-2"))
#expect(publishedIDs.isEmpty)
#expect(statusChangedIDs.isEmpty)
}
}
@@ -194,26 +194,18 @@ final class PerformanceBaselineTests: XCTestCase {
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
}
// MARK: - 4a. Delivery location index (incremental path)
// MARK: - 4a. Delivery status updates through the coordinator (store path)
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` against a warm
/// location index: 2000 public + 50x40 private messages, 500 status
/// updates per pass. Statuses alternate sent <-> delivered so every call
/// performs a real update (never the skip path).
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the
/// `ConversationStore`'s message-ID conversation map: 2000 public
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
/// private messages, 500 status updates per pass. Statuses alternate
/// sent <-> delivered so every call performs a real update (never the
/// skip path).
func testDeliveryStatusIncrementalUpdates() {
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
let coordinator = ChatDeliveryCoordinator(context: context)
// Warm the index so the measure block exercises the incremental path.
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
// 250 public + 250 private IDs spread across the corpus.
var targetIDs: [String] = []
for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) }
let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id }
for (offset, peer) in peerIDs.enumerated() where offset < 25 {
guard let chat = context.privateChats[peer] else { continue }
for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) }
}
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
XCTAssertEqual(targetIDs.count, 500)
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
@@ -235,46 +227,38 @@ final class PerformanceBaselineTests: XCTestCase {
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
}
// MARK: - 4b. Delivery location index (rebuild path)
// MARK: - 4b. Delivery status updates against the store directly
/// The expensive path: a non-append insertion (out-of-order arrival at
/// the head of the timeline) invalidates the index, so the next status
/// update triggers a full rebuild over ~4000 message locations.
func testDeliveryStatusIndexRebuild() {
/// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same
/// scale as 4a, without the coordinator/context wrapping the store-side
/// cost of an ID-only delivery update (map lookup + per-conversation
/// ID-index apply + change emission). Replaces the deleted
/// `delivery.indexRebuild` benchmark: the positional location index and
/// its rebuild path no longer exist; the store's ID indexes are
/// maintained inside each mutation, so there is no rebuild to measure.
func testDeliveryStatusStoreUpdates() {
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
let coordinator = ChatDeliveryCoordinator(context: context)
// Warm the index before the first insertion invalidates it.
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
let store = context.store
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
XCTAssertEqual(targetIDs.count, 500)
// Pre-built head insertions, one consumed per measure pass.
let insertions: [BitchatMessage] = (0..<32).map { i in
BitchatMessage(
id: "insert-\(i)",
sender: "alice",
content: "out-of-order arrival \(i)",
timestamp: Date(timeIntervalSince1970: 1_690_000_000),
isRelay: false
)
}
let probeID = context.messages[1000].id
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
var pass = 0
var toggle = false
var samples: [TimeInterval] = []
measure {
precondition(pass < insertions.count, "add more pre-built insertions")
context.messages.insert(insertions[pass], at: 0)
pass += 1
toggle.toggle()
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
let start = Date()
let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status)
var updated = 0
for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) {
updated += 1
}
samples.append(Date().timeIntervalSince(start))
XCTAssertTrue(updated)
XCTAssertEqual(updated, targetIDs.count)
}
reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds")
reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
}
// MARK: - 5. Message formatting
@@ -328,6 +312,176 @@ final class PerformanceBaselineTests: XCTestCase {
reportThroughput("formatting.formatMessage", samples: samples, operations: batchSize, unit: "messages")
}
// MARK: - 6a. End-to-end private ingest pipeline (current architecture)
/// Baseline for the full private-message ingest cycle through a real
/// `ChatViewModel`: `handlePrivateMessage` `ConversationStore.append`
/// intent (ordered insert + ID-index dedup) per-conversation publish +
/// `changes` emission `PrivateInboxModel` (direct store reads).
/// Measures wall time from first ingest until the store AND the feature
/// model both reflect every message. The peer is not mesh-active and no
/// chat is selected, so notification/read-receipt side paths stay cold
/// (notifications are no-ops under test anyway).
func testPipelinePrivateIngest() {
let messageCount = 200
// 64-hex (stable Noise key) peer ID: skips the short-ID consolidation
// and ephemeral-mirror paths so every pass does identical work.
let peerID = PeerID(str: String(repeating: "ab", count: 32))
let base = Date(timeIntervalSince1970: 1_700_000_000)
let messages: [BitchatMessage] = (0..<messageCount).map { i in
BitchatMessage(
id: "perf-dm-\(i)",
sender: "perfsender",
content: "private pipeline message \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: peerID
)
}
// Fresh fixture per pass so dedup scans and store syncs start from the
// same empty state every iteration. Kept alive so weakly captured
// coordinator Task hops stay valid.
var keepAlive: [PerfPipelineFixture] = []
var samples: [TimeInterval] = []
measure {
let fixture = PerfPipelineFixture()
keepAlive.append(fixture)
let start = Date()
for message in messages {
fixture.viewModel.handlePrivateMessage(message)
}
// Reads are synchronous against the single-writer store; the
// spin covers any coordinator main-actor hops.
let consistent = spinMainRunLoop(timeout: 10) {
fixture.conversations.conversationsByID[.directPeer(peerID)]?.messages.count == messageCount
&& fixture.privateInbox.messages(for: peerID).count == messageCount
}
samples.append(Date().timeIntervalSince(start))
XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged")
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
}
reportThroughput("pipeline.privateIngest", samples: samples, operations: messageCount, unit: "messages")
}
// MARK: - 6b. End-to-end public ingest pipeline (current architecture)
/// Baseline for the full public-message ingest cycle through a real
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
/// main-actor Task hop per message) `handlePublicMessage` (rate limit,
/// pipeline enqueue) `PublicMessagePipeline` timer-batched flush into
/// the `ConversationStore` (derived `ChatViewModel.messages` view;
/// `PublicChatModel` observes the active `Conversation` directly).
/// Measures until `messages` and the feature model reflect every message,
/// so the pipeline's flush latency is part of the cycle. Senders are
/// spread 4-per-peer to stay under the 5-token sender rate bucket.
func testPipelinePublicIngest() {
let messageCount = 200
let senderCount = 50
let base = Date(timeIntervalSince1970: 1_700_000_000)
struct InboundPublic {
let peerID: PeerID
let nickname: String
let content: String
let timestamp: Date
let messageID: String
}
let items: [InboundPublic] = (0..<messageCount).map { i in
let sender = i % senderCount
return InboundPublic(
peerID: PeerID(str: String(format: "%016x", 0xC0DE_0000 + sender)),
nickname: "perfpeer\(sender)",
content: "public pipeline message \(i) from sender \(sender)",
timestamp: base.addingTimeInterval(Double(i)),
messageID: "perf-pub-\(i)"
)
}
let expectedIDs = Set(items.map(\.messageID))
var keepAlive: [PerfPipelineFixture] = []
var samples: [TimeInterval] = []
measure {
let fixture = PerfPipelineFixture()
keepAlive.append(fixture)
let start = Date()
for item in items {
fixture.viewModel.didReceivePublicMessage(
from: item.peerID,
nickname: item.nickname,
content: item.content,
timestamp: item.timestamp,
messageID: item.messageID
)
}
// Drain the per-message main-actor hops and whichever surfacing
// path wins (the pipeline's batched timer flush or the startup
// channel apply's `refreshVisibleMessages`); `PublicChatModel`
// reads the same conversation synchronously.
let consistent = spinMainRunLoop(timeout: 10) {
fixture.viewModel.messages.count >= messageCount
&& fixture.publicChat.messages.count >= messageCount
}
samples.append(Date().timeIntervalSince(start))
XCTAssertTrue(consistent, "messages/PublicChatModel never converged")
let ingested = fixture.viewModel.messages.filter { expectedIDs.contains($0.id) }
XCTAssertEqual(ingested.count, messageCount)
}
reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages")
}
// 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
/// timers) until `condition` holds or `timeout` elapses.
private func spinMainRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while !condition() {
guard Date() < deadline else { return false }
RunLoop.current.run(until: Date().addingTimeInterval(0.005))
}
return true
}
// MARK: - Fixtures
/// Builds deterministic signed kind-20000 geohash events. Content cycles
@@ -396,13 +550,11 @@ private final class PerfNostrContext: ChatNostrContext {
}
var messages: [BitchatMessage] = []
func resetPublicMessagePipeline() {}
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
func flushPublicMessagePipeline() {}
func refreshVisibleMessages(from channel: ChannelID?) {}
func addPublicSystemMessage(_ content: String) {}
func drainPendingGeohashSystemMessages() -> [String] { [] }
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true }
func synchronizePublicConversationStore(forGeohash geohash: String) {}
private(set) var handledPublicMessageCount = 0
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
@@ -469,39 +621,63 @@ private final class PerfNostrContext: ChatNostrContext {
// MARK: - Mock ChatDeliveryContext
/// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the
/// coordinator is a thin mapper onto store intents, so the measured cost is
/// the store's ID-map delivery path).
@MainActor
private final class PerfDeliveryContext: ChatDeliveryContext {
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let store = ConversationStore()
var sentReadReceipts: Set<String> = []
var isStartupPhase: Bool { false }
private var publicIDs: [String] = []
private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = []
func notifyUIChanged() {}
func markMessageDelivered(_ messageID: String) {}
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
store.setDeliveryStatus(status, forMessageID: messageID)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
store.deliveryStatus(forMessageID: messageID)
}
func privateMessageIDs() -> Set<String> {
store.directMessageIDs()
}
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
return oldCount - sentReadReceipts.count
}
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
/// deterministic IDs and timestamps.
/// `publicCount` public messages (split evenly between mesh and one
/// geohash conversation so the corpus stays under the per-conversation
/// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into
/// the store with deterministic IDs and timestamps.
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
let context = PerfDeliveryContext()
let base = Date(timeIntervalSince1970: 1_700_000_000)
context.messages = (0..<publicCount).map { i in
BitchatMessage(
let geohashID = ConversationID.geohash("u4pruyd")
for i in 0..<publicCount {
let message = BitchatMessage(
id: "pub-\(i)",
sender: "peer\(i % 20)",
content: "public message number \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
)
context.store.append(message, to: i % 2 == 0 ? .mesh : geohashID)
context.publicIDs.append(message.id)
}
for p in 0..<peerCount {
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
BitchatMessage(
var messageIDs: [String] = []
for i in 0..<messagesPerPeer {
let message = BitchatMessage(
id: "dm-\(p)-\(i)",
sender: "peer\(p)",
content: "private message \(i) for peer \(p)",
@@ -511,10 +687,68 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
recipientNickname: "me",
senderPeerID: peerID
)
context.store.append(message, to: .directPeer(peerID))
messageIDs.append(message.id)
}
context.privateIDsByPeer.append((peerID, messageIDs))
}
return context
}
/// Deterministic update targets spread across the corpus: `publicTargets`
/// public IDs plus `privateTargets` private IDs.
func makeTargetIDs(publicTargets: Int, privateTargets: Int) -> [String] {
var targetIDs: [String] = []
let publicStride = max(1, publicIDs.count / publicTargets)
for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets {
targetIDs.append(publicIDs[i])
}
var privateCount = 0
outer: for (_, messageIDs) in privateIDsByPeer {
for i in stride(from: 0, to: messageIDs.count, by: 4) {
guard privateCount < privateTargets else { break outer }
targetIDs.append(messageIDs[i])
privateCount += 1
}
}
return targetIDs
}
}
// MARK: - End-to-end pipeline fixture
/// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style
/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same
/// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover
/// the full ingest-to-feature-model chain. Mirrors the construction used by
/// `ChatViewModelExtensionsTests`.
@MainActor
private final class PerfPipelineFixture {
let viewModel: ChatViewModel
let transport: MockTransport
let conversations: ConversationStore
let privateInbox: PrivateInboxModel
let publicChat: PublicChatModel
init() {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let conversations = ConversationStore()
self.transport = transport
self.conversations = conversations
self.viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
conversations: conversations
)
self.privateInbox = PrivateInboxModel(conversations: conversations)
self.publicChat = PublicChatModel(conversations: conversations)
}
}
// MARK: - Mock MessageFormattingContext
+53 -89
View File
@@ -2,7 +2,10 @@
// PublicMessagePipelineTests.swift
// bitchatTests
//
// Tests for PublicMessagePipeline ordering and deduplication.
// Tests for PublicMessagePipeline batching, content dedup, and per-message
// conversation routing. Ordering and ID dedup live in the ConversationStore
// the flush commits into (the old late-insert threshold is gone; see
// ConversationStoreTests for ordered-insert coverage).
//
import Testing
@@ -13,14 +16,15 @@ import BitFoundation
@MainActor
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
private let dedupService = MessageDeduplicationService()
var messages: [BitchatMessage] = []
/// Commits in arrival-at-commit order, per conversation.
private(set) var committed: [(message: BitchatMessage, conversationID: ConversationID)] = []
/// Message IDs the commit rejects (simulates the store's ID dedup).
var rejectedMessageIDs: Set<String> = []
private(set) var recordedContentKeys: [String] = []
private(set) var batchingStates: [Bool] = []
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
messages
}
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
self.messages = messages
func messages(in conversationID: ConversationID) -> [BitchatMessage] {
committed.filter { $0.conversationID == conversationID }.map(\.message)
}
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
@@ -33,19 +37,37 @@ private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
dedupService.recordContentKey(key, timestamp: timestamp)
recordedContentKeys.append(key)
}
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool {
guard !rejectedMessageIDs.contains(message.id) else { return false }
committed.append((message, conversationID))
return true
}
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
batchingStates.append(isBatching)
}
}
@MainActor
private func makeMessage(id: String, content: String, timestamp: Date) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "A",
content: content,
timestamp: timestamp,
isRelay: false
)
}
struct PublicMessagePipelineTests {
@Test @MainActor
func flush_sortsByTimestamp() async {
func flush_commitsInTimestampOrder() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
@@ -53,26 +75,13 @@ struct PublicMessagePipelineTests {
let earlier = Date().addingTimeInterval(-10)
let later = Date()
let messageA = BitchatMessage(
id: "a",
sender: "A",
content: "Later",
timestamp: later,
isRelay: false
)
let messageB = BitchatMessage(
id: "b",
sender: "A",
content: "Earlier",
timestamp: earlier,
isRelay: false
)
pipeline.enqueue(messageA)
pipeline.enqueue(messageB)
pipeline.enqueue(makeMessage(id: "a", content: "Later", timestamp: later), to: .mesh)
pipeline.enqueue(makeMessage(id: "b", content: "Earlier", timestamp: earlier), to: .mesh)
pipeline.flushIfNeeded()
#expect(delegate.messages.map { $0.id } == ["b", "a"])
#expect(delegate.messages(in: .mesh).map { $0.id } == ["b", "a"])
// Batching state wrapped the flush.
#expect(delegate.batchingStates == [true, false])
}
@Test @MainActor
@@ -82,86 +91,41 @@ struct PublicMessagePipelineTests {
pipeline.delegate = delegate
let now = Date()
let messageA = BitchatMessage(
id: "a",
sender: "A",
content: "Same",
timestamp: now,
isRelay: false
)
let messageB = BitchatMessage(
id: "b",
sender: "A",
content: "Same",
timestamp: now.addingTimeInterval(0.2),
isRelay: false
)
pipeline.enqueue(messageA)
pipeline.enqueue(messageB)
pipeline.enqueue(makeMessage(id: "a", content: "Same", timestamp: now), to: .mesh)
pipeline.enqueue(makeMessage(id: "b", content: "Same", timestamp: now.addingTimeInterval(0.2)), to: .mesh)
pipeline.flushIfNeeded()
#expect(delegate.messages.count == 1)
#expect(delegate.messages.first?.content == "Same")
#expect(delegate.messages(in: .mesh).count == 1)
#expect(delegate.messages(in: .mesh).first?.content == "Same")
}
@Test @MainActor
func lateInsert_meshAppendsRecentOlderMessage() async {
func flush_routesEachMessageToItsConversation() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
pipeline.updateActiveChannel(.mesh)
let base = Date()
let newer = BitchatMessage(
id: "new",
sender: "A",
content: "New",
timestamp: base,
isRelay: false
)
let older = BitchatMessage(
id: "old",
sender: "A",
content: "Old",
timestamp: base.addingTimeInterval(-5),
isRelay: false
)
delegate.messages = [newer]
pipeline.enqueue(older)
pipeline.enqueue(makeMessage(id: "mesh-1", content: "mesh hello", timestamp: base), to: .mesh)
// A channel switch mid-batch must not misroute already-buffered messages.
pipeline.enqueue(makeMessage(id: "geo-1", content: "geo hello", timestamp: base.addingTimeInterval(1)), to: .geohash("u4pruydq"))
pipeline.flushIfNeeded()
#expect(delegate.messages.map { $0.id } == ["new", "old"])
#expect(delegate.messages(in: .mesh).map { $0.id } == ["mesh-1"])
#expect(delegate.messages(in: .geohash("u4pruydq")).map { $0.id } == ["geo-1"])
}
@Test @MainActor
func lateInsert_locationInsertsByTimestamp() async {
func flush_rejectedCommitDoesNotRecordContentKey() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
delegate.rejectedMessageIDs = ["dup"]
let base = Date()
let newer = BitchatMessage(
id: "new",
sender: "A",
content: "New",
timestamp: base,
isRelay: false
)
let older = BitchatMessage(
id: "old",
sender: "A",
content: "Old",
timestamp: base.addingTimeInterval(-5),
isRelay: false
)
delegate.messages = [newer]
pipeline.enqueue(older)
pipeline.enqueue(makeMessage(id: "dup", content: "already stored", timestamp: Date()), to: .mesh)
pipeline.flushIfNeeded()
#expect(delegate.messages.map { $0.id } == ["old", "new"])
#expect(delegate.messages(in: .mesh).isEmpty)
#expect(delegate.recordedContentKeys.isEmpty)
}
}
-115
View File
@@ -1,115 +0,0 @@
import Foundation
import BitFoundation
import Testing
@testable import bitchat
@Suite("PublicTimelineStore Tests")
struct PublicTimelineStoreTests {
@Test("Mesh timeline deduplicates and trims to cap")
func meshTimelineDeduplicatesAndTrims() {
var store = PublicTimelineStore(meshCap: 2, geohashCap: 2)
let first = TestHelpers.createTestMessage(content: "one")
let second = TestHelpers.createTestMessage(content: "two")
let third = TestHelpers.createTestMessage(content: "three")
store.append(first, to: .mesh)
store.append(second, to: .mesh)
store.append(first, to: .mesh)
store.append(third, to: .mesh)
let messages = store.messages(for: .mesh)
#expect(messages.map(\.content) == ["two", "three"])
}
@Test("Timeline indexes allow trimmed message IDs to return")
func timelineIndexesAllowTrimmedMessageIDsToReturn() {
var store = PublicTimelineStore(meshCap: 2, geohashCap: 2)
let first = timelineMessage(id: "one", content: "one", timestamp: 1)
let second = timelineMessage(id: "two", content: "two", timestamp: 2)
let third = timelineMessage(id: "three", content: "three", timestamp: 3)
store.append(first, to: .mesh)
store.append(second, to: .mesh)
store.append(third, to: .mesh)
store.append(first, to: .mesh)
#expect(store.messages(for: .mesh).map(\.content) == ["three", "one"])
let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let geoFirst = timelineMessage(id: "geo-one", content: "geo one", timestamp: 1)
let geoSecond = timelineMessage(id: "geo-two", content: "geo two", timestamp: 2)
let geoThird = timelineMessage(id: "geo-three", content: "geo three", timestamp: 3)
let didAppendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash)
let didAppendGeoSecond = store.appendIfAbsent(geoSecond, toGeohash: geohash)
let didAppendGeoThird = store.appendIfAbsent(geoThird, toGeohash: geohash)
let didReappendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash)
#expect(didAppendGeoFirst)
#expect(didAppendGeoSecond)
#expect(didAppendGeoThird)
#expect(didReappendGeoFirst)
#expect(store.messages(for: channel).map(\.content) == ["geo one", "geo three"])
}
@Test("Geohash appendIfAbsent remove and clear work together")
func geohashStoreSupportsAppendRemoveAndClear() {
var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)
let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let first = TestHelpers.createTestMessage(content: "geo one")
let second = TestHelpers.createTestMessage(content: "geo two")
let didAppendFirst = store.appendIfAbsent(first, toGeohash: geohash)
let didAppendDuplicate = store.appendIfAbsent(first, toGeohash: geohash)
#expect(didAppendFirst)
#expect(!didAppendDuplicate)
store.append(second, toGeohash: geohash)
let removed = store.removeMessage(withID: first.id)
#expect(removed?.id == first.id)
#expect(store.messages(for: channel).map(\.content) == ["geo two"])
store.clear(channel: channel)
#expect(store.messages(for: channel).isEmpty)
}
@Test("Mutate geohash updates stored messages in place")
func mutateGeohashAppliesTransformation() {
var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)
let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let first = TestHelpers.createTestMessage(content: "geo one")
store.append(first, toGeohash: geohash)
store.mutateGeohash(geohash) { timeline in
timeline.append(TestHelpers.createTestMessage(content: "geo two"))
}
#expect(store.messages(for: channel).map(\.content) == ["geo one", "geo two"])
}
@Test("Queued geohash system messages drain once")
func pendingGeohashSystemMessagesDrainOnce() {
var store = PublicTimelineStore(meshCap: 1, geohashCap: 1)
store.queueGeohashSystemMessage("first")
store.queueGeohashSystemMessage("second")
#expect(store.drainPendingGeohashSystemMessages() == ["first", "second"])
#expect(store.drainPendingGeohashSystemMessages().isEmpty)
}
private func timelineMessage(id: String, content: String, timestamp: TimeInterval) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: content,
timestamp: Date(timeIntervalSince1970: timestamp),
isRelay: false
)
}
}
@@ -3,6 +3,8 @@
// bitchatTests
//
// Tests for PrivateChatManager read receipt and selection behavior.
// Message storage lives in the single-writer ConversationStore; the
// manager's privateChats/unreadMessages are derived views over it.
//
import Testing
@@ -12,13 +14,20 @@ import BitFoundation
struct PrivateChatManagerTests {
@MainActor
private static func makeManager(transport: MockTransport) -> (PrivateChatManager, ConversationStore) {
let store = ConversationStore()
let manager = PrivateChatManager(meshService: transport, conversationStore: store)
return (manager, store)
}
@Test @MainActor
func startChat_setsSelectedAndClearsUnread() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
let peerID = PeerID(str: "00000000000000AA")
manager.privateChats[peerID] = [
store.append(
BitchatMessage(
id: "pm-1",
sender: "Peer",
@@ -28,9 +37,10 @@ struct PrivateChatManagerTests {
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
manager.unreadMessages.insert(peerID)
),
to: .directPeer(peerID)
)
store.markUnread(.directPeer(peerID))
manager.startChat(with: peerID)
@@ -43,13 +53,13 @@ struct PrivateChatManagerTests {
func markAsRead_sendsReadReceiptViaRouter() async {
let transport = MockTransport()
let router = MessageRouter(transports: [transport])
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
manager.messageRouter = router
let peerID = PeerID(str: "00000000000000BB")
transport.reachablePeers.insert(peerID)
manager.privateChats[peerID] = [
store.append(
BitchatMessage(
id: "pm-2",
sender: "Peer",
@@ -59,9 +69,10 @@ struct PrivateChatManagerTests {
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
manager.unreadMessages.insert(peerID)
),
to: .directPeer(peerID)
)
store.markUnread(.directPeer(peerID))
manager.markAsRead(from: peerID)
try? await Task.sleep(nanoseconds: 100_000_000)
@@ -74,10 +85,10 @@ struct PrivateChatManagerTests {
@Test @MainActor
func markAsRead_withoutRouterFallsBackToTransport() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
let peerID = PeerID(str: "00000000000000CC")
manager.privateChats[peerID] = [
store.append(
BitchatMessage(
id: "pm-fallback",
sender: "Peer",
@@ -87,8 +98,9 @@ struct PrivateChatManagerTests {
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
),
to: .directPeer(peerID)
)
manager.markAsRead(from: peerID)
@@ -99,7 +111,7 @@ struct PrivateChatManagerTests {
@Test @MainActor
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
let identityManager = MockIdentityManager(MockKeychain())
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)
@@ -120,7 +132,7 @@ struct PrivateChatManagerTests {
])
try? await Task.sleep(nanoseconds: 50_000_000)
manager.privateChats[stablePeerID] = [
store.append(
BitchatMessage(
id: "stable-msg",
sender: "Alice",
@@ -130,9 +142,10 @@ struct PrivateChatManagerTests {
isPrivate: true,
recipientNickname: "Me",
senderPeerID: stablePeerID
)
]
manager.unreadMessages.insert(stablePeerID)
),
to: .directPeer(stablePeerID)
)
store.markUnread(.directPeer(stablePeerID))
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: [])
@@ -146,11 +159,11 @@ struct PrivateChatManagerTests {
@Test @MainActor
func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
let peerID = PeerID(str: "0011223344556677")
let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042")
manager.privateChats[tempPeerID] = [
store.append(
BitchatMessage(
id: "geo-msg",
sender: "Alice",
@@ -160,9 +173,10 @@ struct PrivateChatManagerTests {
isPrivate: true,
recipientNickname: "Me",
senderPeerID: tempPeerID
)
]
manager.unreadMessages.insert(tempPeerID)
),
to: .directPeer(tempPeerID)
)
store.markUnread(.directPeer(tempPeerID))
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: [])
@@ -177,10 +191,10 @@ struct PrivateChatManagerTests {
@Test @MainActor
func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
let peerID = PeerID(str: "00000000000000DD")
manager.privateChats[peerID] = [
let seeded = [
BitchatMessage(
id: "sent-read",
sender: "Me",
@@ -215,6 +229,9 @@ struct PrivateChatManagerTests {
deliveryStatus: .failed(reason: "nope")
)
]
for message in seeded {
store.append(message, to: .directPeer(peerID))
}
var externalReceipts = Set<String>()
manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts)
@@ -223,47 +240,36 @@ struct PrivateChatManagerTests {
#expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"]))
}
/// The store replaces `sanitizeChat`: inserts keep chronological order,
/// duplicate IDs are rejected on append, and `upsertByID` replaces the
/// stored message with the latest copy in place.
@Test @MainActor
func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async {
func store_keepsChronologicalOrderAndDedupsByID() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let (manager, store) = Self.makeManager(transport: transport)
let peerID = PeerID(str: "00000000000000EE")
let base = Date(timeIntervalSince1970: 10)
manager.privateChats[peerID] = [
func message(_ id: String, _ content: String, offset: TimeInterval) -> BitchatMessage {
BitchatMessage(
id: "same",
id: id,
sender: "Peer",
content: "Older",
timestamp: base.addingTimeInterval(10),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
),
BitchatMessage(
id: "first",
sender: "Peer",
content: "First",
timestamp: base,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
),
BitchatMessage(
id: "same",
sender: "Peer",
content: "Newest",
timestamp: base.addingTimeInterval(20),
content: content,
timestamp: base.addingTimeInterval(offset),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
}
manager.sanitizeChat(for: peerID)
#expect(store.append(message("same", "Older", offset: 10), to: .directPeer(peerID)))
// Out-of-order arrival is inserted in timestamp order.
#expect(store.append(message("first", "First", offset: 0), to: .directPeer(peerID)))
// Duplicate ID is rejected on append
#expect(!store.append(message("same", "Newest", offset: 20), to: .directPeer(peerID)))
// and replaced in place by upsert.
store.upsertByID(message("same", "Newest", offset: 20), in: .directPeer(peerID))
#expect(manager.privateChats[peerID]?.map(\.id) == ["first", "same"])
#expect(manager.privateChats[peerID]?.last?.content == "Newest")
@@ -138,6 +138,46 @@ enum TestError: Error {
case testFailure(String)
}
// MARK: - Private chat seeding (ConversationStore migration)
extension ChatViewModel {
/// Test-only replacement for the deleted `privateChats` setter: seeds a
/// peer's chat through the single-writer `ConversationStore` intents
/// (upsert keeps re-seeding with updated copies working the way the old
/// dictionary assignment did).
@MainActor
func seedPrivateChat(_ messages: [BitchatMessage], for peerID: PeerID) {
_ = conversations.conversation(for: .directPeer(peerID))
for message in messages {
conversations.upsertByID(message, in: .directPeer(peerID))
}
}
/// Test-only replacement for the deleted `messages` setter: seeds a
/// public channel's conversation through the single-writer
/// `ConversationStore` intents (upsert keeps re-seeding with updated
/// copies working the way the old array assignment did).
@MainActor
func seedPublicMessages(_ messages: [BitchatMessage], for channel: ChannelID = .mesh) {
for message in messages {
conversations.upsertByID(message, in: ConversationID(channelID: channel))
}
}
/// Test-only replacement for `messages.removeAll()`: empties a public
/// channel's conversation.
@MainActor
func clearPublicMessages(for channel: ChannelID = .mesh) {
conversations.clear(ConversationID(channelID: channel))
}
/// Test-only: drops every private chat and unread flag.
@MainActor
func clearAllPrivateChats() {
conversations.removeAllDirectConversations()
}
}
func sleep(_ seconds: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
}
+7 -7
View File
@@ -52,17 +52,17 @@ private func makeSmokeLocationManager() -> LocationChannelManager {
@MainActor
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
let locationManager = makeSmokeLocationManager()
let conversationStore = viewModel.conversationStore
let publicChatModel = PublicChatModel(conversationStore: conversationStore)
let conversations = viewModel.conversations
let publicChatModel = PublicChatModel(conversations: conversations)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let privateInboxModel = PrivateInboxModel(conversations: conversations)
let appChromeModel = AppChromeModel(
chatViewModel: viewModel,
privateInboxModel: privateInboxModel
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
@@ -72,11 +72,11 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: conversationStore
conversations: conversations
)
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: locationChannelsModel
)
@@ -359,7 +359,7 @@ struct ViewSmokeTests {
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
])
try? await Task.sleep(nanoseconds: 50_000_000)
viewModel.unreadPrivateMessages.insert(blockedPeer)
viewModel.markPrivateChatUnread(blockedPeer)
_ = mount(
MeshPeerList(
+169
View File
@@ -0,0 +1,169 @@
# Conversation Store: Single Source of Truth
**Status: migration complete (steps 15).** `ConversationStore` is the sole
holder of message state; the feature models (`PublicChatModel`,
`PrivateInboxModel`, `PrivateConversationModel`, `PeerListModel`,
`ConversationUIModel`) observe it directly — `PublicChatModel` observes the
active `Conversation` object, so background appends never invalidate it.
`LegacyConversationStore`, `LegacyConversationStoreBridge`, and
`PublicTimelineStore` are deleted. Baselines recorded in
`bitchatTests/Performance/PerformanceBaselineTests.swift`
(`pipeline.privateIngest`, `pipeline.publicIngest`, `store.append`,
`delivery.incrementalUpdate`, `delivery.storeUpdate`).
Deviations from the plan below, chosen at cutover:
- **No `IdentityResolver` canonicalization layer.** Direct conversations stay
keyed by raw routing `PeerID` (`ConversationID.directPeer`). The
coordinators' ephemeral↔stable mirroring/consolidation guarantees the
selected peer's key always holds the full timeline, and no view enumerates
direct conversations as a list — the legacy resolver-keyed dedup only ever
fed `isEmpty`-style badge checks (identity-aware unread resolution lives in
`ChatUnreadStateResolver`, which works on raw keys). `IdentityResolver` was
deleted with the legacy store; `PeerHandle` slimmed to `id` +
`routingPeerID`.
- **Selection state lives in the store.** The legacy store also carried the
two UI selection axes (`activeChannel`, `selectedPrivatePeerID`); they
moved into `ConversationStore` (`setActiveChannel` /
`setSelectedPrivatePeer`, deriving `selectedConversationID`), and
`migrateConversation` hands the private-peer selection off with the
conversation.
- **`ChatViewModel.messages` / `privateChats` / `unreadPrivateMessages`
survive as derived read-only views** (not stored properties): coordinators,
commands, and tests read them; the dict shape is only rebuilt where a
coordinator genuinely needs the whole dictionary (migration scans, unread
resolution). Simple per-peer reads dispatch through the store-direct
`privateMessages(for:)` context witness instead.
---
## 1. Problem
Message state is replicated across four stores, kept eventually consistent by three
async bridges. One inbound private message today:
1. `ChatPrivateConversationCoordinator.handlePrivateMessage` writes through
`ChatViewModel.privateChats` — a passthrough computed property
(`ChatViewModel.swift:167-173`) into `PrivateChatManager`'s
`@Published var privateChats` (`PrivateChatManager.swift:16`).
2. **Bridge 1:** the bootstrapper subscribes `privateChatManager.$privateChats` and
`$unreadMessages` with `.receive(on: DispatchQueue.main)` sinks
(`ChatViewModelBootstrapper.swift:92-108`).
3. **Bridge 2:** each sink calls `schedulePrivateConversationStoreSynchronization`,
a `Task.yield`-debounced task (`ChatViewModel.swift:1084-1092`) that eventually runs
`synchronizePrivateConversationStore` (`ChatViewModel.swift:1095-1101`).
4. That calls `ConversationStore.synchronizePrivateChats` — a **full-dict replace**: every
conversation is re-normalized (dedup + `O(n log n)` sort) and diff-compared on every
sync (`AppArchitecture.swift:304-346`, `normalized` at `AppArchitecture.swift:359-372`).
5. **Bridge 3:** `PrivateInboxModel` subscribes `conversationStore.$messagesByConversation`
(again `.receive(on: DispatchQueue.main)`) and rebuilds its entire
`messagesByPeerID` dictionary via `refreshMessages`
(`PrivateConversationModels.swift:43-48`, `54-68`).
6. SwiftUI finally observes the feature model.
Costs and hazards:
- **O(total messages) × 3 layers per single message.** One append re-sorts, re-compares,
and re-publishes every conversation through store and feature-model layers. The ingest
path itself is also quadratic: `isDuplicateMessage` linearly scans *all* private chats
per inbound message (`ChatPrivateConversationCoordinator.swift:622-630`) and
`sanitizeChat` re-sorts the whole chat per append (`PrivateChatManager.swift:213-234`).
- **Delivery status mutates two copies.** `ChatDeliveryCoordinator` patches both
`context.messages` and a value-copied `context.privateChats`
(`ChatDeliveryCoordinator.swift:105-139`), navigating with a positional
`messageLocationIndex` (`ChatDeliveryCoordinator.swift:40`) that any non-append
mutation invalidates, forcing a full rebuild over every message location
(`ChatDeliveryCoordinator.swift:298-320`).
- **Transient disagreement.** Between the `@Published` write and the debounced sync,
`privateChatManager.privateChats` and `ConversationStore.messagesByConversation`
disagree; anything reading the store mid-flight sees stale data.
The public path has the same shape: `@Published var messages`
(`ChatViewModel.swift:122`) is the render copy, `PublicTimelineStore`
(`ChatViewModel.swift:342-345`) is the backing copy, and `handlePublicMessage` appends to
the timeline, then full-replaces the conversation store **per message**
(`ChatPublicConversationCoordinator.swift:504-545` calling
`synchronizePublicConversationStore` at `:358-364`, which funnels into
`ConversationStore.replaceMessages`'s whole-array compare at `AppArchitecture.swift:249-253`),
while a timer-batched `PublicMessagePipeline` mutates `messages` ~80 ms later
(`PublicMessagePipeline.swift`, `TransportConfig.basePublicFlushInterval`).
`PublicChatModel` then mirrors the store again (`PublicChatModel.swift`).
## 2. Design
`ConversationStore` (already `@MainActor` and owned by `AppRuntime`,
`AppRuntime.swift:46`) becomes the **sole writer and sole holder** of message state.
- **`Conversation` is a reference-type `ObservableObject`**, one instance per
`ConversationID` (`.mesh` / `.geohash` / `.direct`), with `@Published private(set)`
`messages` and unread state. Each conversation maintains its message-ID index
**incrementally** (insert on append, never rebuilt from scratch) and owns its cap
policy: `TransportConfig.meshTimelineCap` / `geoTimelineCap` / `privateChatCap`
(`TransportConfig.swift:17-19`) fold into the store; `PublicTimelineStore`'s trim logic
and `PrivateChatManager`'s cap disappear.
- **Publishing granularity is per conversation.** Views observe ONE `Conversation`
object. An append to chat A never invalidates observers of chat B — unlike today,
where any write republishes the entire `messagesByConversation` dictionary
(`AppArchitecture.swift:205`) and every bound feature model rebuilds.
- **Store-level `changes: PassthroughSubject<ConversationChange, Never>`** for non-UI
consumers (delivery tracking, notifications, gossip/sync) that need "a message was
appended / status changed in conversation X" without subscribing to message arrays.
- **Mutations go through an intent API only**, mirroring the codebase's existing
single-writer intent ops (`ChatViewModel.swift:421-424`, the `private(set)` +
dedicated-mutator pattern):
- `append(_:to:)` — incremental, dedup via the ID index
- `upsertByID(_:in:)` — replace-or-append (media progress, edits)
- `setDeliveryStatus(_:for:in:)` — keyed by message ID, no positional index
- `markRead(_:)` / `markUnread(_:)`
- `migrateConversation(from:to:)` — the ephemeral↔stable peer-ID handoff that today
is hand-rolled dictionary surgery in three places
- `clear(_:)`
Backing collections are `private(set)`; coordinators receive the intent surface, not
the dictionaries.
- **Reads are synchronous.** Because writers and readers share the main actor and there
is one copy, "await the sync" disappears: after `append` returns, every observer of
that `Conversation` sees the message.
## 3. Deleted at end state (done)
- `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into
`Conversation` cap/dedup policy.
- `PrivateChatManager`'s message dict and trim/sanitize logic — the manager shrinks to
read-receipt policy (`markAsRead`, `syncReadReceiptsForSentMessages`).
- `ChatDeliveryCoordinator.messageLocationIndex` and its growth/rebuild machinery
(`ChatDeliveryCoordinator.swift:40-45`, `221-320`) — replaced by
`setDeliveryStatus(for:in:)` against the per-conversation ID index.
- Both bootstrapper sync bridges (`ChatViewModelBootstrapper.swift:92-108`).
- `schedulePrivateConversationStoreSynchronization` /
`synchronizePrivateConversationStore` and the public equivalents
(`ChatViewModel.swift:1084-1101`, `ChatPublicConversationCoordinator.swift:351-386`).
- Feature-model mirror collections: `PrivateInboxModel.messagesByPeerID`,
`PublicChatModel.messages` (they observe `Conversation` objects directly).
- `ChatViewModel.messages` / `ChatViewModel.privateChats` as stored/owning properties.
## 4. Migration plan (complete)
Each step lands green against the full suite plus the `PerformanceBaselineTests`
numbers (no pipeline throughput regression at any step).
1. **Additive store.** Introduce `Conversation` objects and the intent API inside
`ConversationStore` alongside the existing replace-based API. Nothing reads them yet.
2. **Private cutover with compat shims.** Inbound/outbound private paths write through
the intent API. `ChatViewModel.privateChats` becomes a derived **read-only** view of
the store; `PrivateChatManager`'s dict and the private sync bridges are bypassed but
the property surface stays so coordinators/tests compile unchanged.
3. **Public cutover.** `handlePublicMessage` and the `PublicMessagePipeline` flush write
to the store; `PublicTimelineStore` folds in; `ChatViewModel.messages` becomes a
derived view of the active conversation.
4. **Delivery via store.** `ChatDeliveryCoordinator` switches to
`setDeliveryStatus(for:in:)`; `messageLocationIndex` is deleted.
5. **View cutover.** Views and feature models observe `Conversation` objects directly;
delete all shims, mirrors, and the replace-based store API.
## 5. Non-goals
- **No message persistence.** bitchat is ephemeral by design; the store stays in-memory.
- **`sentReadReceipts` UserDefaults persistence stays put** (`ChatViewModel.swift:394-406`);
it is receipt-protocol state, not conversation state.
- **`MessageRouter`'s outbox remains the SSOT for unsent messages**; the store records
delivery status but never owns retry/resend queues.