mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
/// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves
|
||||
/// timestamp order, carries unread state over, and hands off the
|
||||
/// selection — mirroring `ChatPrivateConversationCoordinator`'s
|
||||
/// migration semantics. The source conversation is removed. Emits a
|
||||
/// single `.migrated(from:to:)` once the whole move is consistent.
|
||||
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
|
||||
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
|
||||
|
||||
let destinationConversation = conversation(for: destination)
|
||||
for message in sourceConversation.messages {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -184,86 +190,66 @@ final class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Start a private chat with a peer
|
||||
@MainActor
|
||||
func startChat(with peerID: PeerID) {
|
||||
selectedPeer = peerID
|
||||
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
selectedPeerFingerprint = fingerprint
|
||||
}
|
||||
|
||||
|
||||
// Mark messages as read
|
||||
markAsRead(from: peerID)
|
||||
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
conversationStore?.conversation(for: .directPeer(peerID))
|
||||
}
|
||||
|
||||
|
||||
/// End the current private chat
|
||||
func endChat() {
|
||||
selectedPeer = nil
|
||||
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
|
||||
}
|
||||
/// 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) {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
guard !sentReadReceipts.contains(message.id),
|
||||
let senderPeerID = message.senderPeerID else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
sentReadReceipts.insert(message.id)
|
||||
|
||||
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
|
||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||
if let router = messageRouter {
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/// (ephemeral↔stable 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Conversation Store: Single Source of Truth
|
||||
|
||||
**Status: migration complete (steps 1–5).** `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.
|
||||
Reference in New Issue
Block a user