mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +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 {
|
struct PeerHandle: Sendable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let routingPeerID: PeerID
|
let routingPeerID: PeerID
|
||||||
let displayName: String?
|
|
||||||
let noisePublicKeyHex: String?
|
|
||||||
let nostrPublicKey: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PeerHandle: Equatable {
|
extension PeerHandle: Equatable {
|
||||||
@@ -100,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 {
|
final class AppRuntime: ObservableObject {
|
||||||
let chatViewModel: ChatViewModel
|
let chatViewModel: ChatViewModel
|
||||||
let events = AppEventStream()
|
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 peerIdentityStore: PeerIdentityStore
|
||||||
let locationPresenceStore: LocationPresenceStore
|
let locationPresenceStore: LocationPresenceStore
|
||||||
let publicChatModel: PublicChatModel
|
let publicChatModel: PublicChatModel
|
||||||
@@ -42,30 +45,28 @@ final class AppRuntime: ObservableObject {
|
|||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
let identityResolver = IdentityResolver()
|
let conversations = ConversationStore()
|
||||||
let conversationStore = ConversationStore()
|
|
||||||
let peerIdentityStore = PeerIdentityStore()
|
let peerIdentityStore = PeerIdentityStore()
|
||||||
let locationPresenceStore = LocationPresenceStore()
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
let locationManager = LocationChannelManager.shared
|
let locationManager = LocationChannelManager.shared
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
self.peerIdentityStore = peerIdentityStore
|
self.peerIdentityStore = peerIdentityStore
|
||||||
self.locationPresenceStore = locationPresenceStore
|
self.locationPresenceStore = locationPresenceStore
|
||||||
self.chatViewModel = ChatViewModel(
|
self.chatViewModel = ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: SecureIdentityStateManager(keychain),
|
identityManager: SecureIdentityStateManager(keychain),
|
||||||
conversationStore: conversationStore,
|
conversations: conversations,
|
||||||
identityResolver: identityResolver,
|
|
||||||
peerIdentityStore: peerIdentityStore,
|
peerIdentityStore: peerIdentityStore,
|
||||||
locationPresenceStore: locationPresenceStore,
|
locationPresenceStore: locationPresenceStore,
|
||||||
locationManager: locationManager
|
locationManager: locationManager
|
||||||
)
|
)
|
||||||
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
self.publicChatModel = PublicChatModel(conversations: conversations)
|
||||||
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||||
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||||
self.privateConversationModel = PrivateConversationModel(
|
self.privateConversationModel = PrivateConversationModel(
|
||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: conversations,
|
||||||
locationChannelsModel: self.locationChannelsModel,
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
peerIdentityStore: peerIdentityStore
|
peerIdentityStore: peerIdentityStore
|
||||||
)
|
)
|
||||||
@@ -77,11 +78,11 @@ final class AppRuntime: ObservableObject {
|
|||||||
self.conversationUIModel = ConversationUIModel(
|
self.conversationUIModel = ConversationUIModel(
|
||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
privateConversationModel: self.privateConversationModel,
|
privateConversationModel: self.privateConversationModel,
|
||||||
conversationStore: conversationStore
|
conversations: conversations
|
||||||
)
|
)
|
||||||
self.peerListModel = PeerListModel(
|
self.peerListModel = PeerListModel(
|
||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: conversations,
|
||||||
locationChannelsModel: self.locationChannelsModel,
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
peerIdentityStore: peerIdentityStore,
|
peerIdentityStore: peerIdentityStore,
|
||||||
locationPresenceStore: locationPresenceStore
|
locationPresenceStore: locationPresenceStore
|
||||||
@@ -218,7 +219,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
userInfo: [AnyHashable: Any]
|
userInfo: [AnyHashable: Any]
|
||||||
) async -> UNNotificationPresentationOptions {
|
) async -> UNNotificationPresentationOptions {
|
||||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
if conversationStore.selectedPrivatePeerID == peerID {
|
if conversations.selectedPrivatePeerID == peerID {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return [.banner, .sound]
|
return [.banner, .sound]
|
||||||
|
|||||||
@@ -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 chatViewModel: ChatViewModel
|
||||||
private let privateConversationModel: PrivateConversationModel
|
private let privateConversationModel: PrivateConversationModel
|
||||||
private let conversationStore: ConversationStore
|
private let conversations: ConversationStore
|
||||||
private var activeChannel: ChannelID
|
private var activeChannel: ChannelID
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
privateConversationModel: PrivateConversationModel,
|
privateConversationModel: PrivateConversationModel,
|
||||||
conversationStore: ConversationStore
|
conversations: ConversationStore
|
||||||
) {
|
) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.privateConversationModel = privateConversationModel
|
self.privateConversationModel = privateConversationModel
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
self.activeChannel = conversationStore.activeChannel
|
self.activeChannel = conversations.activeChannel
|
||||||
self.currentNickname = chatViewModel.nickname
|
self.currentNickname = chatViewModel.nickname
|
||||||
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||||
self.showAutocomplete = chatViewModel.showAutocomplete
|
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||||
@@ -151,7 +151,7 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$isBatchingPublic)
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
conversationStore.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
self?.activeChannel = channel
|
self?.activeChannel = channel
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ final class PeerListModel: ObservableObject {
|
|||||||
@Published private(set) var renderID = ""
|
@Published private(set) var renderID = ""
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private let conversationStore: ConversationStore
|
private let conversations: ConversationStore
|
||||||
private let locationChannelsModel: LocationChannelsModel
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
private let peerIdentityStore: PeerIdentityStore
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
private let locationPresenceStore: LocationPresenceStore
|
private let locationPresenceStore: LocationPresenceStore
|
||||||
@@ -45,13 +45,13 @@ final class PeerListModel: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
conversationStore: ConversationStore,
|
conversations: ConversationStore,
|
||||||
locationChannelsModel: LocationChannelsModel? = nil,
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
peerIdentityStore: PeerIdentityStore? = nil,
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
locationPresenceStore: LocationPresenceStore? = nil
|
locationPresenceStore: LocationPresenceStore? = nil
|
||||||
) {
|
) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||||
@@ -122,7 +122,7 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
conversationStore.$unreadConversations
|
conversations.$unreadConversations
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.refresh()
|
self?.refresh()
|
||||||
|
|||||||
@@ -2,69 +2,93 @@ import BitFoundation
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Feature model for private (direct) conversations.
|
||||||
|
///
|
||||||
|
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
|
||||||
|
/// returns the peer's conversation backing array (no mirror dictionary), and
|
||||||
|
/// the store's typed `changes` subject drives invalidation — a change in the
|
||||||
|
/// SELECTED peer's conversation republishes this model, while appends to
|
||||||
|
/// other private chats only surface through the unread set. Direct
|
||||||
|
/// conversations are keyed by raw routing peer ID; the coordinators'
|
||||||
|
/// ephemeral/stable mirroring guarantees the selected peer's key always
|
||||||
|
/// holds the full timeline (see `ConversationID.directPeer`).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PrivateInboxModel: ObservableObject {
|
final class PrivateInboxModel: ObservableObject {
|
||||||
@Published private(set) var selectedPeerID: PeerID?
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||||
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
|
||||||
|
|
||||||
private let conversationStore: ConversationStore
|
private let conversations: ConversationStore
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(conversationStore: ConversationStore) {
|
init(conversations: ConversationStore) {
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
|
self.selectedPeerID = conversations.selectedPrivatePeerID
|
||||||
|
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
||||||
|
|
||||||
bind()
|
bind()
|
||||||
refreshMessages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
guard let peerID else { return [] }
|
guard let peerID else { return [] }
|
||||||
return messagesByPeerID[peerID] ?? []
|
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bind() {
|
private func bind() {
|
||||||
conversationStore.$selectedPrivatePeerID
|
conversations.$selectedPrivatePeerID
|
||||||
.receive(on: DispatchQueue.main)
|
.dropFirst()
|
||||||
.sink { [weak self] peerID in
|
.sink { [weak self] peerID in
|
||||||
self?.selectedPeerID = peerID
|
guard let self, self.selectedPeerID != peerID else { return }
|
||||||
self?.refreshMessages()
|
self.selectedPeerID = peerID
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
conversationStore.$unreadConversations
|
conversations.changes
|
||||||
.receive(on: DispatchQueue.main)
|
.sink { [weak self] change in
|
||||||
.sink { [weak self] _ in
|
self?.apply(change)
|
||||||
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
|
|
||||||
self?.refreshMessages()
|
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
conversationStore.$messagesByConversation
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.refreshMessages()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
selectedPeerID = conversationStore.selectedPrivatePeerID
|
|
||||||
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshMessages() {
|
private func apply(_ change: ConversationChange) {
|
||||||
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
|
switch change {
|
||||||
var peerIDs = Set(nextMessagesByPeerID.keys)
|
case .appended(let id, _),
|
||||||
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
|
.updated(let id, _),
|
||||||
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
|
.statusChanged(let id, _, _),
|
||||||
peerIDs.insert(selectedPeerID)
|
.messageRemoved(let id, _),
|
||||||
}
|
.cleared(let id):
|
||||||
|
republishIfSelected(id)
|
||||||
|
|
||||||
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
|
case .unreadChanged(let id, _):
|
||||||
nextMessagesByPeerID[peerID] = []
|
guard isDirect(id) else { return }
|
||||||
}
|
refreshUnreadPeerIDs()
|
||||||
|
|
||||||
guard messagesByPeerID != nextMessagesByPeerID else { return }
|
case .removed(let id):
|
||||||
messagesByPeerID = nextMessagesByPeerID
|
guard isDirect(id) else { return }
|
||||||
|
refreshUnreadPeerIDs()
|
||||||
|
republishIfSelected(id)
|
||||||
|
|
||||||
|
case .migrated(let source, let destination):
|
||||||
|
guard isDirect(source) || isDirect(destination) else { return }
|
||||||
|
refreshUnreadPeerIDs()
|
||||||
|
republishIfSelected(source)
|
||||||
|
republishIfSelected(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func republishIfSelected(_ id: ConversationID) {
|
||||||
|
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshUnreadPeerIDs() {
|
||||||
|
let next = conversations.unreadDirectRoutingPeerIDs()
|
||||||
|
guard unreadPeerIDs != next else { return }
|
||||||
|
unreadPeerIDs = next
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isDirect(_ id: ConversationID) -> Bool {
|
||||||
|
if case .direct = id { return true }
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,22 +118,22 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private let conversationStore: ConversationStore
|
private let conversations: ConversationStore
|
||||||
private let locationChannelsModel: LocationChannelsModel
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
private let peerIdentityStore: PeerIdentityStore
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
conversationStore: ConversationStore,
|
conversations: ConversationStore,
|
||||||
locationChannelsModel: LocationChannelsModel? = nil,
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
peerIdentityStore: PeerIdentityStore? = nil
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
) {
|
) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
let initialPeerID = conversationStore.selectedPrivatePeerID
|
let initialPeerID = conversations.selectedPrivatePeerID
|
||||||
self.selectedPeerID = initialPeerID
|
self.selectedPeerID = initialPeerID
|
||||||
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||||
makeHeaderState(for: peerID)
|
makeHeaderState(for: peerID)
|
||||||
@@ -154,7 +178,7 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bind() {
|
private func bind() {
|
||||||
conversationStore.$selectedPrivatePeerID
|
conversations.$selectedPrivatePeerID
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.refreshSelectedConversation()
|
self?.refreshSelectedConversation()
|
||||||
@@ -198,7 +222,7 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func refreshSelectedConversation() {
|
private func refreshSelectedConversation() {
|
||||||
selectedPeerID = conversationStore.selectedPrivatePeerID
|
selectedPeerID = conversations.selectedPrivatePeerID
|
||||||
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||||
makeHeaderState(for: peerID)
|
makeHeaderState(for: peerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,76 @@ import BitFoundation
|
|||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Feature model for the active public (mesh/geohash) timeline.
|
||||||
|
///
|
||||||
|
/// Observes ONE `Conversation` object in the single-writer
|
||||||
|
/// `ConversationStore` — the active channel's — so appends to background
|
||||||
|
/// conversations (other geohashes, private chats) never invalidate it.
|
||||||
|
/// `messages` reads the observed conversation's backing array directly;
|
||||||
|
/// there is no mirror copy.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PublicChatModel: ObservableObject {
|
final class PublicChatModel: ObservableObject {
|
||||||
@Published private(set) var activeChannel: ChannelID
|
@Published private(set) var activeChannel: ChannelID
|
||||||
@Published private(set) var messages: [BitchatMessage] = []
|
|
||||||
|
|
||||||
private let conversationStore: 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>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(conversationStore: ConversationStore) {
|
init(conversations: ConversationStore) {
|
||||||
self.activeChannel = conversationStore.activeChannel
|
let channel = conversations.activeChannel
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
|
self.activeChannel = channel
|
||||||
|
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||||
|
|
||||||
|
observeActiveConversation()
|
||||||
bind()
|
bind()
|
||||||
refreshMessages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bind() {
|
private func bind() {
|
||||||
conversationStore.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.dropFirst()
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
self?.activeChannel = channel
|
guard let self else { return }
|
||||||
self?.refreshMessages()
|
self.activeChannel = channel
|
||||||
|
self.retargetActiveConversation(to: channel)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
conversationStore.$messagesByConversation
|
// The store replaces a conversation's object when it is removed
|
||||||
.receive(on: DispatchQueue.main)
|
// (panic clear); retarget to the fresh instance so the observation
|
||||||
.sink { [weak self] _ in
|
// never goes stale.
|
||||||
self?.refreshMessages()
|
conversations.changes
|
||||||
|
.sink { [weak self] change in
|
||||||
|
guard let self,
|
||||||
|
case .removed(let id) = change,
|
||||||
|
id == self.activeConversation.id else { return }
|
||||||
|
self.retargetActiveConversation(to: self.activeChannel)
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshMessages() {
|
private func retargetActiveConversation(to channel: ChannelID) {
|
||||||
let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
|
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||||
guard messages != nextMessages else { return }
|
guard conversation !== activeConversation else {
|
||||||
messages = nextMessages
|
// Same object (e.g. re-selected channel): keep the existing
|
||||||
|
// observation, but `messages` may still differ from what views
|
||||||
|
// last rendered, so republish.
|
||||||
|
objectWillChange.send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
objectWillChange.send()
|
||||||
|
activeConversation = conversation
|
||||||
|
observeActiveConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeActiveConversation() {
|
||||||
|
activeConversationCancellable = activeConversation.objectWillChange
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
var activeChannel: ChannelID { get }
|
var activeChannel: ChannelID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var blockedUsers: Set<String> { get }
|
var blockedUsers: Set<String> { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
|
||||||
var idBridge: NostrIdentityBridge { get }
|
var idBridge: NostrIdentityBridge { get }
|
||||||
|
|
||||||
// MARK: - Peer Lookup
|
// MARK: - Peer Lookup
|
||||||
@@ -43,6 +42,8 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
func startPrivateChat(with peerID: PeerID)
|
func startPrivateChat(with peerID: PeerID)
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
||||||
func clearCurrentPublicTimeline()
|
func clearCurrentPublicTimeline()
|
||||||
|
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||||
|
func clearPrivateChat(_ peerID: PeerID)
|
||||||
func sendPublicRaw(_ content: String)
|
func sendPublicRaw(_ content: String)
|
||||||
|
|
||||||
// MARK: - System Messages
|
// MARK: - System Messages
|
||||||
@@ -160,7 +161,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
private func handleClear() -> CommandResult {
|
private func handleClear() -> CommandResult {
|
||||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||||
contextProvider?.privateChats[peerID]?.removeAll()
|
contextProvider?.clearPrivateChat(peerID)
|
||||||
} else {
|
} else {
|
||||||
contextProvider?.clearCurrentPublicTimeline()
|
contextProvider?.clearCurrentPublicTimeline()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import BitFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
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 {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
||||||
@Published var selectedPeer: PeerID? = nil
|
@Published var selectedPeer: PeerID? = nil
|
||||||
@Published var unreadMessages: Set<PeerID> = []
|
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -25,13 +27,34 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
// Peer service for looking up peer info during consolidation
|
// Peer service for looking up peer info during consolidation
|
||||||
weak var unifiedPeerService: UnifiedPeerService?
|
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.meshService = meshService
|
||||||
|
self.conversationStore = conversationStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// MARK: - Derived message state (read-only compat views)
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
|
||||||
|
/// 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
|
// MARK: - Message Consolidation
|
||||||
|
|
||||||
@@ -44,57 +67,51 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
/// - Returns: True if any unread messages were found during consolidation
|
/// - Returns: True if any unread messages were found during consolidation
|
||||||
@MainActor
|
@MainActor
|
||||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
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
|
var hasUnreadMessages = false
|
||||||
|
|
||||||
// 1. Consolidate from stable Noise key (64-char hex)
|
// 1. Consolidate from stable Noise key (64-char hex)
|
||||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
let nostrMessages = messages(for: noiseKeyHex)
|
||||||
|
|
||||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
for message in nostrMessages {
|
for message in nostrMessages {
|
||||||
if !existingMessageIds.contains(message.id) {
|
// Update senderPeerID for correct read receipts
|
||||||
// Update senderPeerID for correct read receipts
|
let updatedMessage = BitchatMessage(
|
||||||
let updatedMessage = BitchatMessage(
|
id: message.id,
|
||||||
id: message.id,
|
sender: message.sender,
|
||||||
sender: message.sender,
|
content: message.content,
|
||||||
content: message.content,
|
timestamp: message.timestamp,
|
||||||
timestamp: message.timestamp,
|
isRelay: message.isRelay,
|
||||||
isRelay: message.isRelay,
|
originalSender: message.originalSender,
|
||||||
originalSender: message.originalSender,
|
isPrivate: message.isPrivate,
|
||||||
isPrivate: message.isPrivate,
|
recipientNickname: message.recipientNickname,
|
||||||
recipientNickname: message.recipientNickname,
|
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
||||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
mentions: message.mentions,
|
||||||
mentions: message.mentions,
|
deliveryStatus: message.deliveryStatus
|
||||||
deliveryStatus: message.deliveryStatus
|
)
|
||||||
)
|
// Store append dedups by message ID (skips ones the
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
// 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)
|
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
||||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
||||||
if message.senderPeerID != meshService.myPeerID {
|
if message.senderPeerID != meshService.myPeerID {
|
||||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
||||||
hasUnreadMessages = true
|
hasUnreadMessages = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
if hasUnreadMessages {
|
if hasUnreadMessages {
|
||||||
unreadMessages.insert(peerID)
|
store.markUnread(.directPeer(peerID))
|
||||||
} else if unreadMessages.contains(noiseKeyHex) {
|
} else {
|
||||||
unreadMessages.remove(noiseKeyHex)
|
store.markRead(.directPeer(noiseKeyHex))
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats.removeValue(forKey: noiseKeyHex)
|
store.removeConversation(.directPeer(noiseKeyHex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,52 +129,43 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !tempPeerIDsToConsolidate.isEmpty {
|
if !tempPeerIDsToConsolidate.isEmpty {
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
var consolidatedCount = 0
|
var consolidatedCount = 0
|
||||||
var hadUnreadTemp = false
|
var hadUnreadTemp = false
|
||||||
|
let unreadPeerIDs = unreadMessages
|
||||||
|
|
||||||
for tempPeerID in tempPeerIDsToConsolidate {
|
for tempPeerID in tempPeerIDsToConsolidate {
|
||||||
if unreadMessages.contains(tempPeerID) {
|
if unreadPeerIDs.contains(tempPeerID) {
|
||||||
hadUnreadTemp = true
|
hadUnreadTemp = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if let tempMessages = privateChats[tempPeerID] {
|
for message in messages(for: tempPeerID) {
|
||||||
for message in tempMessages {
|
let updatedMessage = BitchatMessage(
|
||||||
if !existingMessageIds.contains(message.id) {
|
id: message.id,
|
||||||
let updatedMessage = BitchatMessage(
|
sender: message.sender,
|
||||||
id: message.id,
|
content: message.content,
|
||||||
sender: message.sender,
|
timestamp: message.timestamp,
|
||||||
content: message.content,
|
isRelay: message.isRelay,
|
||||||
timestamp: message.timestamp,
|
originalSender: message.originalSender,
|
||||||
isRelay: message.isRelay,
|
isPrivate: message.isPrivate,
|
||||||
originalSender: message.originalSender,
|
recipientNickname: message.recipientNickname,
|
||||||
isPrivate: message.isPrivate,
|
senderPeerID: peerID,
|
||||||
recipientNickname: message.recipientNickname,
|
mentions: message.mentions,
|
||||||
senderPeerID: peerID,
|
deliveryStatus: message.deliveryStatus
|
||||||
mentions: message.mentions,
|
)
|
||||||
deliveryStatus: message.deliveryStatus
|
if store.append(updatedMessage, to: .directPeer(peerID)) {
|
||||||
)
|
consolidatedCount += 1
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
consolidatedCount += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
privateChats.removeValue(forKey: tempPeerID)
|
|
||||||
unreadMessages.remove(tempPeerID)
|
|
||||||
}
|
}
|
||||||
|
store.removeConversation(.directPeer(tempPeerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
if hadUnreadTemp {
|
if hadUnreadTemp {
|
||||||
unreadMessages.insert(peerID)
|
store.markUnread(.directPeer(peerID))
|
||||||
hasUnreadMessages = true
|
hasUnreadMessages = true
|
||||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
if consolidatedCount > 0 {
|
if consolidatedCount > 0 {
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
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
|
/// Syncs the read receipt tracking between manager and view model for sent messages
|
||||||
@MainActor
|
@MainActor
|
||||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||||
guard let messages = privateChats[peerID] else { return }
|
for message in messages(for: peerID) {
|
||||||
|
|
||||||
for message in messages {
|
|
||||||
if message.sender == nickname {
|
if message.sender == nickname {
|
||||||
if let status = message.deliveryStatus {
|
if let status = message.deliveryStatus {
|
||||||
switch status {
|
switch status {
|
||||||
@@ -184,86 +190,66 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
|
@MainActor
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark messages as read
|
// Mark messages as read
|
||||||
markAsRead(from: peerID)
|
markAsRead(from: peerID)
|
||||||
|
|
||||||
// Initialize chat if needed
|
// Initialize chat if needed
|
||||||
if privateChats[peerID] == nil {
|
conversationStore?.conversation(for: .directPeer(peerID))
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End the current private chat
|
/// End the current private chat
|
||||||
func endChat() {
|
func endChat() {
|
||||||
selectedPeer = nil
|
selectedPeer = nil
|
||||||
selectedPeerFingerprint = nil
|
selectedPeerFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// No-op since the `ConversationStore` cutover: the store maintains
|
||||||
func sanitizeChat(for peerID: PeerID) {
|
/// chronological order and dedups by message ID on every insert, so the
|
||||||
guard let arr = privateChats[peerID] else { return }
|
/// per-append re-sort/dedup sweep this performed is no longer needed.
|
||||||
if arr.count <= 1 {
|
/// Kept only for API compatibility until step 5 removes the callers.
|
||||||
return
|
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
|
/// Mark messages from a peer as read
|
||||||
|
@MainActor
|
||||||
func markAsRead(from peerID: PeerID) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
unreadMessages.remove(peerID)
|
conversationStore?.markRead(.directPeer(peerID))
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
for message in messages(for: peerID) {
|
||||||
for message in messages {
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
sendReadReceipt(for: message)
|
||||||
sendReadReceipt(for: message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !sentReadReceipts.contains(message.id),
|
guard !sentReadReceipts.contains(message.id),
|
||||||
let senderPeerID = message.senderPeerID else {
|
let senderPeerID = message.senderPeerID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
|
|
||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
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
|
static let nostrInboundEventLogInterval: Int = 100
|
||||||
|
|
||||||
// UI thresholds
|
// UI thresholds
|
||||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
|
||||||
// Geohash public chats are more sensitive to ordering; use a tighter threshold
|
|
||||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
static let uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,19 @@ import Foundation
|
|||||||
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatDeliveryContext: AnyObject {
|
protocol ChatDeliveryContext: AnyObject {
|
||||||
var messages: [BitchatMessage] { get set }
|
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
|
||||||
var isStartupPhase: Bool { get }
|
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`.
|
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
|
||||||
/// Returns the number of receipts removed. (Single mutation path for the
|
/// Returns the number of receipts removed. (Single mutation path for the
|
||||||
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
||||||
@@ -26,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatDeliveryContext {
|
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() {
|
func notifyUIChanged() {
|
||||||
objectWillChange.send()
|
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 {
|
final class ChatDeliveryCoordinator {
|
||||||
private unowned let context: any ChatDeliveryContext
|
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) {
|
init(context: any ChatDeliveryContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -50,15 +71,9 @@ final class ChatDeliveryCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cleanupOldReadReceipts() {
|
func cleanupOldReadReceipts() {
|
||||||
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
|
guard !context.isStartupPhase else { return }
|
||||||
return
|
let validMessageIDs = context.privateMessageIDs()
|
||||||
}
|
guard !validMessageIDs.isEmpty else { return }
|
||||||
|
|
||||||
let validMessageIDs = Set(
|
|
||||||
context.privateChats.values.flatMap { messages in
|
|
||||||
messages.map(\.id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||||
if removedCount > 0 {
|
if removedCount > 0 {
|
||||||
@@ -81,9 +96,7 @@ final class ChatDeliveryCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||||
withValidLocations(for: messageID) { locations in
|
context.deliveryStatus(forMessageID: messageID)
|
||||||
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -97,229 +110,10 @@ final class ChatDeliveryCoordinator {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
var didUpdateStatus = false
|
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||||
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:
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
context.notifyUIChanged()
|
||||||
|
return true
|
||||||
@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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,16 @@ import Foundation
|
|||||||
protocol ChatLifecycleContext: AnyObject {
|
protocol ChatLifecycleContext: AnyObject {
|
||||||
// MARK: Chat & receipt state
|
// MARK: Chat & receipt state
|
||||||
var messages: [BitchatMessage] { get }
|
var messages: [BitchatMessage] { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
/// A single private chat's timeline (store-direct lookup on
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
/// `ChatViewModel`; no `privateChats` dictionary build).
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
|
||||||
|
var unreadPrivateMessages: Set<PeerID> { get }
|
||||||
var selectedPrivateChatPeer: 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 sentReadReceipts: Set<String> { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var myPeerID: PeerID { get }
|
var myPeerID: PeerID { get }
|
||||||
@@ -33,7 +40,6 @@ protocol ChatLifecycleContext: AnyObject {
|
|||||||
/// Schedules main-actor work after a UI-timing delay. Injected so tests
|
/// Schedules main-actor work after a UI-timing delay. Injected so tests
|
||||||
/// can run the work synchronously instead of polling wall-clock queues.
|
/// can run the work synchronously instead of polling wall-clock queues.
|
||||||
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
|
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
|
||||||
func synchronizePrivateConversationStore()
|
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: Peers & sessions
|
// MARK: Peers & sessions
|
||||||
@@ -69,11 +75,11 @@ protocol ChatLifecycleContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatLifecycleContext {
|
extension ChatViewModel: ChatLifecycleContext {
|
||||||
// `messages`, `privateChats`, `unreadPrivateMessages`,
|
// `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
|
||||||
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
|
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
|
||||||
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
|
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
|
||||||
// `markPrivateMessagesAsRead(from:)`,
|
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
|
||||||
// `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`,
|
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
|
||||||
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
|
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
|
||||||
// the routing/ack members, `isTeleported`,
|
// the routing/ack members, `isTeleported`,
|
||||||
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
|
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
|
||||||
@@ -178,13 +184,12 @@ final class ChatLifecycleCoordinator {
|
|||||||
|
|
||||||
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||||||
context.markChatAsRead(from: peerID)
|
context.markChatAsRead(from: peerID)
|
||||||
context.synchronizePrivateConversationStore()
|
|
||||||
|
|
||||||
if peerID.isGeoDM,
|
if peerID.isGeoDM,
|
||||||
let recipientHex = context.nostrKeyMapping[peerID],
|
let recipientHex = context.nostrKeyMapping[peerID],
|
||||||
case .location(let channel) = context.activeChannel,
|
case .location(let channel) = context.activeChannel,
|
||||||
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
let messages = context.privateChats[peerID] ?? []
|
let messages = context.privateMessages(for: peerID)
|
||||||
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
||||||
guard !context.sentReadReceipts.contains(message.id) else { continue }
|
guard !context.sentReadReceipts.contains(message.id) else { continue }
|
||||||
|
|
||||||
@@ -215,7 +220,7 @@ final class ChatLifecycleCoordinator {
|
|||||||
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
||||||
|
|
||||||
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
|
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] {
|
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
var combined: [BitchatMessage] = []
|
var combined: [BitchatMessage] = []
|
||||||
|
|
||||||
if let ephemeralMessages = context.privateChats[peerID] {
|
combined.append(contentsOf: context.privateMessages(for: peerID))
|
||||||
combined.append(contentsOf: ephemeralMessages)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let peer = context.unifiedPeer(for: peerID) {
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] {
|
if noiseKeyHex != peerID {
|
||||||
combined.append(contentsOf: stableMessages)
|
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,12 +315,7 @@ private extension ChatLifecycleCoordinator {
|
|||||||
senderPeerID: context.myPeerID
|
senderPeerID: context.myPeerID
|
||||||
)
|
)
|
||||||
|
|
||||||
var chats = context.privateChats
|
context.appendPrivateMessage(notice, to: peerID)
|
||||||
if chats[peerID] == nil {
|
|
||||||
chats[peerID] = []
|
|
||||||
}
|
|
||||||
chats[peerID]?.append(notice)
|
|
||||||
context.privateChats = chats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
|
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
func currentPublicSender() -> (name: String, peerID: PeerID)
|
func currentPublicSender() -> (name: String, peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Message state
|
// MARK: Message state
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
/// Appends a private message via the single-writer store intent.
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
|
@discardableResult
|
||||||
func refreshVisibleMessages(from channel: ChannelID?)
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
||||||
func trimMessagesIfNeeded()
|
/// 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 removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
@@ -48,9 +51,8 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
extension ChatViewModel: ChatMediaTransferContext {
|
extension ChatViewModel: ChatMediaTransferContext {
|
||||||
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
||||||
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
||||||
// `currentPublicSender()`, `privateChats`,
|
// `currentPublicSender()`,
|
||||||
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
|
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
|
||||||
// `trimMessagesIfNeeded()`, `removeMessage(withID:cleanupFile:)`,
|
|
||||||
// `addSystemMessage(_:)`, `notifyUIChanged()`,
|
// `addSystemMessage(_:)`, `notifyUIChanged()`,
|
||||||
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
|
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
|
||||||
// and `recordContentKey(_:timestamp:)` are shared requirements with the
|
// and `recordContentKey(_:timestamp:)` are shared requirements with the
|
||||||
@@ -228,10 +230,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
senderPeerID: context.myPeerID,
|
senderPeerID: context.myPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
var chats = context.privateChats
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
chats[peerID, default: []].append(message)
|
|
||||||
context.privateChats = chats
|
|
||||||
context.trimMessagesIfNeeded()
|
|
||||||
} else {
|
} else {
|
||||||
let (displayName, senderPeerID) = context.currentPublicSender()
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
||||||
message = BitchatMessage(
|
message = BitchatMessage(
|
||||||
@@ -245,9 +244,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
senderPeerID: senderPeerID,
|
senderPeerID: senderPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
context.appendTimelineMessage(message, to: context.activeChannel)
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||||
context.refreshVisibleMessages(from: context.activeChannel)
|
|
||||||
context.trimMessagesIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = context.normalizedContentKey(message.content)
|
let key = context.normalizedContentKey(message.content)
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ protocol ChatOutgoingContext: AnyObject {
|
|||||||
|
|
||||||
// MARK: Public timeline (local echo)
|
// MARK: Public timeline (local echo)
|
||||||
func parseMentions(from content: String) -> [String]
|
func parseMentions(from content: String) -> [String]
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
|
/// Appends a public message via the single-writer store intent
|
||||||
func refreshVisibleMessages(from channel: ChannelID?)
|
/// (immediate: the local echo must render without batching).
|
||||||
func trimMessagesIfNeeded()
|
@discardableResult
|
||||||
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: Content dedup
|
// MARK: Content dedup
|
||||||
@@ -50,8 +51,7 @@ extension ChatViewModel: ChatOutgoingContext {
|
|||||||
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
|
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
|
||||||
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
|
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
|
||||||
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
|
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
|
||||||
// `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`,
|
// `appendPublicMessage(_:to:)`, `addSystemMessage(_:)`,
|
||||||
// `trimMessagesIfNeeded()`, `addSystemMessage(_:)`,
|
|
||||||
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
|
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
|
||||||
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
|
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
|
||||||
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
|
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
|
||||||
@@ -169,12 +169,10 @@ private extension ChatOutgoingCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendLocalEcho(_ message: BitchatMessage) {
|
func appendLocalEcho(_ message: BitchatMessage) {
|
||||||
context.appendTimelineMessage(message, to: context.activeChannel)
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||||
context.refreshVisibleMessages(from: context.activeChannel)
|
|
||||||
|
|
||||||
let contentKey = context.normalizedContentKey(message.content)
|
let contentKey = context.normalizedContentKey(message.content)
|
||||||
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||||
context.trimMessagesIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func routePublicMessage(
|
func routePublicMessage(
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatPeerIdentityContext: AnyObject {
|
protocol ChatPeerIdentityContext: AnyObject {
|
||||||
// MARK: Conversation state
|
// MARK: Conversation state
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get }
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
/// 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 selectedPrivateChatPeer: PeerID? { get set }
|
||||||
var selectedPrivateChatFingerprint: String? { get set }
|
var selectedPrivateChatFingerprint: String? { get set }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
@@ -39,7 +47,6 @@ protocol ChatPeerIdentityContext: AnyObject {
|
|||||||
func syncReadReceiptsForSentMessages(for peerID: PeerID)
|
func syncReadReceiptsForSentMessages(for peerID: PeerID)
|
||||||
/// Re-targets the private chat session in the chat manager (no store-sync side effects).
|
/// Re-targets the private chat session in the chat manager (no store-sync side effects).
|
||||||
func beginPrivateChatSession(with peerID: PeerID)
|
func beginPrivateChatSession(with peerID: PeerID)
|
||||||
func synchronizePrivateConversationStore()
|
|
||||||
func synchronizeConversationSelectionStore()
|
func synchronizeConversationSelectionStore()
|
||||||
func markPrivateMessagesAsRead(from peerID: PeerID)
|
func markPrivateMessagesAsRead(from peerID: PeerID)
|
||||||
|
|
||||||
@@ -225,7 +232,7 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func openMostRelevantPrivateChat() {
|
func openMostRelevantPrivateChat() {
|
||||||
let unreadSorted = context.unreadPrivateMessages
|
let unreadSorted = context.unreadPrivateMessages
|
||||||
.map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
|
.map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) }
|
||||||
.sorted { $0.1 > $1.1 }
|
.sorted { $0.1 > $1.1 }
|
||||||
if let target = unreadSorted.first?.0 {
|
if let target = unreadSorted.first?.0 {
|
||||||
startPrivateChat(with: target)
|
startPrivateChat(with: target)
|
||||||
@@ -305,9 +312,7 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
context.selectedPrivateChatPeer = currentPeerID
|
context.selectedPrivateChatPeer = currentPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
var unread = context.unreadPrivateMessages
|
context.markPrivateChatRead(currentPeerID)
|
||||||
unread.remove(currentPeerID)
|
|
||||||
context.unreadPrivateMessages = unread
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -367,7 +372,6 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
context.selectedPrivateChatFingerprint = nil
|
context.selectedPrivateChatFingerprint = nil
|
||||||
}
|
}
|
||||||
context.beginPrivateChatSession(with: peerID)
|
context.beginPrivateChatSession(with: peerID)
|
||||||
context.synchronizePrivateConversationStore()
|
|
||||||
context.synchronizeConversationSelectionStore()
|
context.synchronizeConversationSelectionStore()
|
||||||
context.markPrivateMessagesAsRead(from: peerID)
|
context.markPrivateMessagesAsRead(from: peerID)
|
||||||
}
|
}
|
||||||
@@ -553,37 +557,16 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
||||||
if let oldMessages = context.privateChats[oldPeerID] {
|
// The store migration dedups by message ID, preserves timestamp
|
||||||
var chats = context.privateChats
|
// order, carries the unread flag, and removes the old chat.
|
||||||
chats[newPeerID, default: []].append(contentsOf: oldMessages)
|
context.migratePrivateChat(from: oldPeerID, to: newPeerID)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
|
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
|
||||||
if context.selectedPrivateChatPeer == oldPeerID {
|
if context.selectedPrivateChatPeer == oldPeerID {
|
||||||
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
|
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
|
||||||
} else if context.privateChats[oldPeerID] != nil {
|
} else if !context.privateMessages(for: oldPeerID).isEmpty {
|
||||||
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
|
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +616,7 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
|
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
|
||||||
let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
|
let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender
|
||||||
let plan = ChatFavoriteTogglePolicy.plan(
|
let plan = ChatFavoriteTogglePolicy.plan(
|
||||||
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
|
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
|
||||||
fallbackNickname: fallbackNickname,
|
fallbackNickname: fallbackNickname,
|
||||||
@@ -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 {
|
protocol ChatPeerListContext: AnyObject {
|
||||||
// MARK: Connection & chat state
|
// MARK: Connection & chat state
|
||||||
var isConnected: Bool { get set }
|
var isConnected: Bool { get set }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get }
|
/// A single private chat's timeline (store-direct lookup on
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
/// `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 }
|
var hasTrackedPrivateChatSelection: Bool { get }
|
||||||
func updatePrivateChatPeerIfNeeded()
|
func updatePrivateChatPeerIfNeeded()
|
||||||
func cleanupOldReadReceipts()
|
func cleanupOldReadReceipts()
|
||||||
@@ -35,7 +39,7 @@ protocol ChatPeerListContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatPeerListContext {
|
extension ChatViewModel: ChatPeerListContext {
|
||||||
// `isConnected`, `privateChats`, `unreadPrivateMessages`,
|
// `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`,
|
||||||
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
|
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
|
||||||
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
|
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
|
||||||
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
|
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
|
||||||
@@ -146,16 +150,16 @@ private extension ChatPeerListCoordinator {
|
|||||||
var idsToRemove: [PeerID] = []
|
var idsToRemove: [PeerID] = []
|
||||||
|
|
||||||
for staleID in staleIDs {
|
for staleID in staleIDs {
|
||||||
if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty {
|
if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty {
|
if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
idsToRemove.append(staleID)
|
idsToRemove.append(staleID)
|
||||||
context.unreadPrivateMessages.remove(staleID)
|
context.markPrivateChatRead(staleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !idsToRemove.isEmpty {
|
if !idsToRemove.isEmpty {
|
||||||
|
|||||||
@@ -14,13 +14,42 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatPrivateConversationContext: AnyObject {
|
protocol ChatPrivateConversationContext: AnyObject {
|
||||||
// MARK: Conversation state
|
// 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 sentReadReceipts: Set<String> { get }
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
var unreadPrivateMessages: Set<PeerID> { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var activeChannel: ChannelID { get }
|
var activeChannel: ChannelID { get }
|
||||||
var nostrKeyMapping: [PeerID: String] { 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`.
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -65,10 +94,9 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
|
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
|
||||||
|
|
||||||
// MARK: System messages & chat hygiene
|
// MARK: System messages
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
func addMeshOnlySystemMessage(_ content: String)
|
func addMeshOnlySystemMessage(_ content: String)
|
||||||
func sanitizeChat(for peerID: PeerID)
|
|
||||||
|
|
||||||
// MARK: Favorites & notifications
|
// MARK: Favorites & notifications
|
||||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
@@ -165,10 +193,6 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
addSystemMessage(content, timestamp: Date())
|
addSystemMessage(content, timestamp: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeChat(for peerID: PeerID) {
|
|
||||||
privateChatManager.sanitizeChat(for: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
||||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||||
}
|
}
|
||||||
@@ -249,10 +273,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
|
|
||||||
if context.privateChats[peerID] == nil {
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
context.privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
context.privateChats[peerID]?.append(message)
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
|
|
||||||
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
||||||
@@ -262,15 +283,15 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
recipientNickname: recipientNickname ?? "user",
|
recipientNickname: recipientNickname ?? "user",
|
||||||
messageID: messageID
|
messageID: messageID
|
||||||
)
|
)
|
||||||
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
||||||
context.privateChats[peerID]?[idx].deliveryStatus = .sent
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(
|
||||||
context.privateChats[peerID]?[index].deliveryStatus = .failed(
|
.failed(
|
||||||
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
||||||
)
|
),
|
||||||
}
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
let name = recipientNickname ?? "user"
|
let name = recipientNickname ?? "user"
|
||||||
context.addSystemMessage(
|
context.addSystemMessage(
|
||||||
String(
|
String(
|
||||||
@@ -303,28 +324,28 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
|
|
||||||
if context.privateChats[peerID] == nil {
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
context.privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
context.privateChats[peerID]?.append(message)
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
|
|
||||||
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
||||||
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(
|
||||||
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
.failed(
|
||||||
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
||||||
)
|
),
|
||||||
}
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||||
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(
|
||||||
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
.failed(
|
||||||
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
||||||
)
|
),
|
||||||
}
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
context.addSystemMessage(
|
context.addSystemMessage(
|
||||||
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
||||||
)
|
)
|
||||||
@@ -334,11 +355,13 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
do {
|
do {
|
||||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
||||||
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(
|
||||||
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
|
.failed(
|
||||||
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
||||||
)
|
),
|
||||||
}
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,15 +375,15 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
from: identity,
|
from: identity,
|
||||||
messageID: messageID
|
messageID: messageID
|
||||||
)
|
)
|
||||||
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
||||||
context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
context.setPrivateDeliveryStatus(
|
||||||
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
|
.failed(
|
||||||
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||||
for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -400,17 +420,14 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||||
)
|
)
|
||||||
|
|
||||||
if context.privateChats[convKey] == nil {
|
context.appendPrivateMessage(message, to: convKey)
|
||||||
context.privateChats[convKey] = []
|
|
||||||
}
|
|
||||||
context.privateChats[convKey]?.append(message)
|
|
||||||
|
|
||||||
let isViewing = context.selectedPrivateChatPeer == convKey
|
let isViewing = context.selectedPrivateChatPeer == convKey
|
||||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||||
if shouldMarkUnread {
|
if shouldMarkUnread {
|
||||||
context.unreadPrivateMessages.insert(convKey)
|
context.markPrivateChatUnread(convKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isViewing {
|
if isViewing {
|
||||||
@@ -427,10 +444,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
||||||
context.privateChats[convKey]?[idx].deliveryStatus = .delivered(
|
context.setPrivateDeliveryStatus(
|
||||||
to: context.displayNameForNostrPubkey(senderPubkey),
|
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
at: Date()
|
forMessageID: messageID,
|
||||||
|
peerID: convKey
|
||||||
)
|
)
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
@@ -445,10 +463,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
||||||
context.privateChats[convKey]?[idx].deliveryStatus = .read(
|
context.setPrivateDeliveryStatus(
|
||||||
by: context.displayNameForNostrPubkey(senderPubkey),
|
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
at: Date()
|
forMessageID: messageID,
|
||||||
|
peerID: convKey
|
||||||
)
|
)
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
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) {
|
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
|
||||||
let stableKeyHex = PeerID(hexData: peerNoiseKey)
|
let stableKeyHex = PeerID(hexData: peerNoiseKey)
|
||||||
|
let nostrMessages = context.privateMessages(for: stableKeyHex)
|
||||||
if stableKeyHex != peerID,
|
if stableKeyHex != peerID,
|
||||||
let nostrMessages = context.privateChats[stableKeyHex],
|
|
||||||
!nostrMessages.isEmpty {
|
!nostrMessages.isEmpty {
|
||||||
if context.privateChats[peerID] == nil {
|
// Store migration dedups by ID, keeps timestamp order, and
|
||||||
context.privateChats[peerID] = []
|
// removes the stable-key chat.
|
||||||
}
|
context.migratePrivateChat(from: stableKeyHex, to: 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)
|
|
||||||
|
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
|
"📥 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.sendMeshReadReceipt(receipt, to: peerID)
|
||||||
context.markReadReceiptSent(message.id)
|
context.markReadReceiptSent(message.id)
|
||||||
} else {
|
} else {
|
||||||
context.unreadPrivateMessages.insert(peerID)
|
context.markPrivateChatUnread(peerID)
|
||||||
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
|
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.notifyUIChanged()
|
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 {
|
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
|
||||||
if context.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
|
context.privateChatsContainMessage(withID: messageId)
|
||||||
return true
|
|
||||||
}
|
|
||||||
for (_, messages) in context.privateChats where messages.contains(where: { $0.id == messageId }) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
|
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
|
||||||
if context.privateChats[targetPeerID] == nil {
|
// Store upsert replaces in place by message ID or inserts in
|
||||||
context.privateChats[targetPeerID] = []
|
// timestamp order; the old per-append sanitize re-sort is obsolete.
|
||||||
}
|
context.upsertPrivateMessage(message, in: 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
|
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
|
||||||
@@ -649,15 +650,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.privateChats[ephemeralPeerID] == nil {
|
context.upsertPrivateMessage(message, in: ephemeralPeerID)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleViewingThisChat(
|
func handleViewingThisChat(
|
||||||
@@ -666,10 +659,10 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
key: Data?,
|
key: Data?,
|
||||||
senderPubkey: String
|
senderPubkey: String
|
||||||
) {
|
) {
|
||||||
context.unreadPrivateMessages.remove(targetPeerID)
|
context.markPrivateChatRead(targetPeerID)
|
||||||
if let key,
|
if let key,
|
||||||
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
|
||||||
context.unreadPrivateMessages.remove(ephemeralPeerID)
|
context.markPrivateChatRead(ephemeralPeerID)
|
||||||
}
|
}
|
||||||
guard !context.sentReadReceipts.contains(message.id) else { return }
|
guard !context.sentReadReceipts.contains(message.id) else { return }
|
||||||
|
|
||||||
@@ -702,11 +695,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
) {
|
) {
|
||||||
guard shouldMarkAsUnread else { return }
|
guard shouldMarkAsUnread else { return }
|
||||||
|
|
||||||
context.unreadPrivateMessages.insert(targetPeerID)
|
context.markPrivateChatUnread(targetPeerID)
|
||||||
if let key,
|
if let key,
|
||||||
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
||||||
ephemeralPeerID != targetPeerID {
|
ephemeralPeerID != targetPeerID {
|
||||||
context.unreadPrivateMessages.insert(ephemeralPeerID)
|
context.markPrivateChatUnread(ephemeralPeerID)
|
||||||
}
|
}
|
||||||
if isRecentMessage {
|
if isRecentMessage {
|
||||||
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
|
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
|
||||||
@@ -777,9 +770,14 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
|
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
|
||||||
let currentFingerprint = context.getFingerprint(for: peerID)
|
let currentFingerprint = context.getFingerprint(for: peerID)
|
||||||
|
|
||||||
if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true {
|
if context.privateMessages(for: peerID).isEmpty {
|
||||||
var migratedMessages: [BitchatMessage] = []
|
// 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 oldPeerIDsToRemove: [PeerID] = []
|
||||||
|
var didMigrate = false
|
||||||
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
|
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
|
||||||
|
|
||||||
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
|
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
|
||||||
@@ -790,10 +788,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
if let currentFp = currentFingerprint,
|
if let currentFp = currentFingerprint,
|
||||||
let oldFp = oldFingerprint,
|
let oldFp = oldFingerprint,
|
||||||
currentFp == oldFp {
|
currentFp == oldFp {
|
||||||
migratedMessages.append(contentsOf: recentMessages)
|
didMigrate = true
|
||||||
if recentMessages.count == messages.count {
|
if recentMessages.count == messages.count {
|
||||||
oldPeerIDsToRemove.append(oldPeerID)
|
oldPeerIDsToRemove.append(oldPeerID)
|
||||||
} else {
|
} else {
|
||||||
|
partiallyMigratedMessages.append(contentsOf: recentMessages)
|
||||||
SecureLogger.info(
|
SecureLogger.info(
|
||||||
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
|
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
|
||||||
category: .session
|
category: .session
|
||||||
@@ -811,9 +810,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isRelevantChat {
|
if isRelevantChat {
|
||||||
migratedMessages.append(contentsOf: recentMessages)
|
didMigrate = true
|
||||||
if recentMessages.count == messages.count {
|
if recentMessages.count == messages.count {
|
||||||
oldPeerIDsToRemove.append(oldPeerID)
|
oldPeerIDsToRemove.append(oldPeerID)
|
||||||
|
} else {
|
||||||
|
partiallyMigratedMessages.append(contentsOf: recentMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
@@ -826,21 +827,22 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
|
|
||||||
if !oldPeerIDsToRemove.isEmpty {
|
if !oldPeerIDsToRemove.isEmpty {
|
||||||
for oldID in oldPeerIDsToRemove {
|
for oldID in oldPeerIDsToRemove {
|
||||||
context.privateChats.removeValue(forKey: oldID)
|
// The old behavior dropped the unread flag of removed
|
||||||
context.unreadPrivateMessages.remove(oldID)
|
// 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.clearStoredFingerprint(for: oldID)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
|
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !migratedMessages.isEmpty {
|
for message in partiallyMigratedMessages {
|
||||||
if context.privateChats[peerID] == nil {
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
context.privateChats[peerID] = []
|
}
|
||||||
}
|
|
||||||
context.privateChats[peerID]?.append(contentsOf: migratedMessages)
|
if didMigrate {
|
||||||
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
context.sanitizeChat(for: peerID)
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,3 +879,11 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default for conforming test contexts that model chats as a dictionary;
|
||||||
|
/// `ChatViewModel` overrides with a store-direct lookup.
|
||||||
|
extension ChatPrivateConversationContext {
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
privateChats[peerID] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import UIKit
|
|||||||
/// pipeline.
|
/// pipeline.
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ChatPublicConversationContext: AnyObject {
|
protocol ChatPublicConversationContext: AnyObject {
|
||||||
// MARK: Channel & visible timeline state
|
// MARK: Channel state
|
||||||
var messages: [BitchatMessage] { get set }
|
|
||||||
var activeChannel: ChannelID { get }
|
var activeChannel: ChannelID { get }
|
||||||
var currentGeohash: String? { get }
|
var currentGeohash: String? { get }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
@@ -31,29 +30,34 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
func setPublicBatching(_ isBatching: Bool)
|
func setPublicBatching(_ isBatching: Bool)
|
||||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
func trimMessagesIfNeeded()
|
|
||||||
|
|
||||||
// MARK: Public timeline store
|
// MARK: Public conversation store (single-writer intents)
|
||||||
func timelineMessages(for channel: ChannelID) -> [BitchatMessage]
|
/// Appends a public message in timestamp order. Returns `false` when a
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
|
/// 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 appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||||
func removeTimelineMessage(withID id: String) -> BitchatMessage?
|
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool
|
||||||
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool)
|
/// Removes a message by ID from whichever public conversation contains it.
|
||||||
func clearTimeline(for channel: ChannelID)
|
@discardableResult
|
||||||
func timelineGeohashKeys() -> [String]
|
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.
|
/// Queues a system message for the next geohash channel visit.
|
||||||
func queueGeohashSystemMessage(_ content: String)
|
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)
|
// MARK: Private chats (block cleanup & message removal)
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
/// Removes the peer's chat entirely, including unread state
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
/// (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)
|
func cleanupLocalFile(forMessage message: BitchatMessage)
|
||||||
|
|
||||||
// MARK: Geohash participants & presence
|
// MARK: Geohash participants & presence
|
||||||
@@ -79,7 +83,9 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
||||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
||||||
func allowPublicMessage(senderKey: String, contentKey: String) -> 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?
|
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
|
||||||
|
|
||||||
// MARK: Content dedup & formatting
|
// MARK: Content dedup & formatting
|
||||||
@@ -95,55 +101,21 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatPublicConversationContext {
|
extension ChatViewModel: ChatPublicConversationContext {
|
||||||
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
|
// `unreadPrivateMessages`, `nostrKeyMapping`,
|
||||||
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
||||||
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
|
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
|
||||||
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
||||||
// `deriveNostrIdentity(forGeohash:)`, and
|
// `deriveNostrIdentity(forGeohash:)`, the public conversation store
|
||||||
// `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements
|
// intents (`appendPublicMessage(_:to:)`,
|
||||||
// with `ChatDeliveryContext` / `ChatPrivateConversationContext` /
|
// `appendGeohashMessageIfAbsent(_:toGeohash:)`,
|
||||||
// `ChatNostrContext`; their witnesses already exist. The members below
|
// `publicConversationContainsMessage(withID:in:)`,
|
||||||
// flatten nested service accesses into intent-named calls.
|
// `removePublicMessage(withID:)`,
|
||||||
|
// `removePublicMessages(fromGeohash:where:)`,
|
||||||
func timelineMessages(for channel: ChannelID) -> [BitchatMessage] {
|
// `clearPublicConversation(_:)`, and `queueGeohashSystemMessage(_:)`)
|
||||||
timelineStore.messages(for: channel)
|
// are shared requirements with `ChatDeliveryContext` /
|
||||||
}
|
// `ChatPrivateConversationContext` / `ChatNostrContext` or satisfied by
|
||||||
|
// existing `ChatViewModel` members. The members below flatten nested
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
|
// service accesses into intent-named calls.
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func visibleGeoPeople() -> [GeoPerson] {
|
func visibleGeoPeople() -> [GeoPerson] {
|
||||||
participantTracker.getVisiblePeople()
|
participantTracker.getVisiblePeople()
|
||||||
@@ -169,8 +141,8 @@ extension ChatViewModel: ChatPublicConversationContext {
|
|||||||
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueuePublicMessage(_ message: BitchatMessage) {
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||||
publicMessagePipeline.enqueue(message)
|
publicMessagePipeline.enqueue(message, to: conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizedContentKey(_ content: String) -> String {
|
func normalizedContentKey(_ content: String) -> String {
|
||||||
@@ -242,23 +214,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
context.removeGeohashTimelineMessages(in: gh, where: predicate)
|
context.removePublicMessages(fromGeohash: gh, where: predicate)
|
||||||
synchronizePublicConversationStore(forGeohash: gh)
|
|
||||||
if case .location = context.activeChannel {
|
|
||||||
context.messages.removeAll(where: predicate)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let conversationPeerID = PeerID(nostr_: hex)
|
// The store intent no-ops when no such chat exists.
|
||||||
if context.privateChats[conversationPeerID] != nil {
|
context.removePrivateChat(PeerID(nostr_: hex))
|
||||||
var privateChats = context.privateChats
|
|
||||||
privateChats.removeValue(forKey: conversationPeerID)
|
|
||||||
context.privateChats = privateChats
|
|
||||||
|
|
||||||
var unread = context.unreadPrivateMessages
|
|
||||||
unread.remove(conversationPeerID)
|
|
||||||
context.unreadPrivateMessages = unread
|
|
||||||
}
|
|
||||||
|
|
||||||
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
||||||
|
|
||||||
@@ -314,33 +274,12 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
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 }) {
|
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
|
||||||
removedMessage = context.messages.remove(at: index)
|
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 {
|
if cleanupFile, let removedMessage {
|
||||||
context.cleanupLocalFile(forMessage: removedMessage)
|
context.cleanupLocalFile(forMessage: removedMessage)
|
||||||
}
|
}
|
||||||
@@ -348,46 +287,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
context.notifyUIChanged()
|
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() {
|
func clearCurrentPublicTimeline() {
|
||||||
context.messages.removeAll()
|
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
|
||||||
context.clearTimeline(for: context.activeChannel)
|
|
||||||
|
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
do {
|
do {
|
||||||
@@ -427,7 +328,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
context.messages.append(systemMessage)
|
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
|
||||||
}
|
}
|
||||||
|
|
||||||
func addMeshOnlySystemMessage(_ content: String) {
|
func addMeshOnlySystemMessage(_ content: String) {
|
||||||
@@ -437,11 +338,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
context.appendTimelineMessage(systemMessage, to: .mesh)
|
context.appendPublicMessage(systemMessage, to: .mesh)
|
||||||
synchronizePublicConversationStore(for: .mesh)
|
|
||||||
refreshVisibleMessages()
|
|
||||||
context.trimMessagesIfNeeded()
|
|
||||||
context.notifyUIChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addPublicSystemMessage(_ content: String) {
|
func addPublicSystemMessage(_ content: String) {
|
||||||
@@ -451,12 +348,9 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
context.appendTimelineMessage(systemMessage, to: context.activeChannel)
|
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
|
||||||
refreshVisibleMessages(from: context.activeChannel)
|
|
||||||
let contentKey = context.normalizedContentKey(systemMessage.content)
|
let contentKey = context.normalizedContentKey(systemMessage.content)
|
||||||
context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||||||
context.trimMessagesIfNeeded()
|
|
||||||
context.notifyUIChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addGeohashOnlySystemMessage(_ content: String) {
|
func addGeohashOnlySystemMessage(_ content: String) {
|
||||||
@@ -506,7 +400,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
if context.isMessageBlocked(finalMessage) { return }
|
if context.isMessageBlocked(finalMessage) { return }
|
||||||
|
|
||||||
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
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 {
|
if shouldRateLimit {
|
||||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
let contentKey = context.normalizedContentKey(finalMessage.content)
|
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" {
|
// Resolve the destination conversation. System messages surface on
|
||||||
context.appendTimelineMessage(finalMessage, to: .mesh)
|
// the active channel (matching their old visible-only routing); geo
|
||||||
synchronizePublicConversationStore(for: .mesh)
|
// 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 = {
|
let channelMatches: Bool = {
|
||||||
switch context.activeChannel {
|
switch context.activeChannel {
|
||||||
case .mesh: return !isGeo || isSystem
|
case .mesh: return !isGeo || isSystem
|
||||||
@@ -536,11 +437,16 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
guard channelMatches else { return }
|
if channelMatches {
|
||||||
|
// Visible-channel arrivals are batched: the pipeline's ~80 ms
|
||||||
if !finalMessage.content.trimmed.isEmpty,
|
// flush commits them to the store (which dedups by ID), keeping
|
||||||
!context.messages.contains(where: { $0.id == finalMessage.id }) {
|
// the deliberate UI flush cadence.
|
||||||
context.enqueuePublicMessage(finalMessage)
|
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
|
#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 {
|
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||||||
context.normalizedContentKey(content)
|
context.normalizedContentKey(content)
|
||||||
}
|
}
|
||||||
@@ -618,8 +516,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
context.recordContentKey(key, timestamp: timestamp)
|
context.recordContentKey(key, timestamp: timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
|
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool {
|
||||||
context.trimMessagesIfNeeded()
|
context.appendPublicMessage(message, to: conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
|
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
|
||||||
|
|||||||
@@ -15,9 +15,19 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
var isConnected: Bool { get set }
|
var isConnected: Bool { get set }
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
var myPeerID: PeerID { get }
|
var myPeerID: PeerID { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
/// A single private chat's timeline (store-direct lookup on
|
||||||
var unreadPrivateMessages: Set<PeerID> { get set }
|
/// `ChatViewModel`; no `privateChats` dictionary build).
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
|
||||||
|
var unreadPrivateMessages: Set<PeerID> { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get set }
|
var selectedPrivateChatPeer: PeerID? { get set }
|
||||||
|
/// Appends a private message via the single-writer store intent;
|
||||||
|
/// 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
|
/// Forgets that read receipts were sent for `ids` so READ acks can be
|
||||||
/// re-sent after the peer reconnects. (Single mutation path for the
|
/// re-sent after the peer reconnects. (Single mutation path for the
|
||||||
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
||||||
@@ -62,7 +72,7 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatTransportEventContext {
|
extension ChatViewModel: ChatTransportEventContext {
|
||||||
// `isConnected`, `nickname`, `myPeerID`, `privateChats`,
|
// `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`,
|
||||||
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
|
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
|
||||||
// the inbound message handlers, `isPeerBlocked(_:)`,
|
// the inbound message handlers, `isPeerBlocked(_:)`,
|
||||||
// `parseMentions(from:)`, `resolveNickname(for:)`,
|
// `parseMentions(from:)`, `resolveNickname(for:)`,
|
||||||
@@ -225,12 +235,10 @@ final class ChatTransportEventCoordinator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let messages = context.privateChats[peerID] {
|
let receiptIDs = context.privateMessages(for: peerID)
|
||||||
let receiptIDs = messages
|
.filter { $0.senderPeerID == peerID }
|
||||||
.filter { $0.senderPeerID == peerID }
|
.map(\.id)
|
||||||
.map(\.id)
|
context.unmarkReadReceiptsSent(receiptIDs)
|
||||||
context.unmarkReadReceiptsSent(receiptIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
@@ -251,13 +259,13 @@ private extension ChatTransportEventCoordinator {
|
|||||||
to stablePeerID: PeerID,
|
to stablePeerID: PeerID,
|
||||||
in context: any ChatTransportEventContext
|
in context: any ChatTransportEventContext
|
||||||
) {
|
) {
|
||||||
if let messages = context.privateChats[shortPeerID] {
|
let hadUnread = context.unreadPrivateMessages.contains(shortPeerID)
|
||||||
if context.privateChats[stablePeerID] == nil {
|
|
||||||
context.privateChats[stablePeerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingIDs = Set(context.privateChats[stablePeerID]?.map(\.id) ?? [])
|
let shortPeerMessages = context.privateMessages(for: shortPeerID)
|
||||||
for message in messages where !existingIDs.contains(message.id) {
|
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(
|
let migrated = BitchatMessage(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
sender: message.sender,
|
sender: message.sender,
|
||||||
@@ -273,16 +281,15 @@ private extension ChatTransportEventCoordinator {
|
|||||||
mentions: message.mentions,
|
mentions: message.mentions,
|
||||||
deliveryStatus: message.deliveryStatus
|
deliveryStatus: message.deliveryStatus
|
||||||
)
|
)
|
||||||
context.privateChats[stablePeerID]?.append(migrated)
|
context.appendPrivateMessage(migrated, to: stablePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
|
context.removePrivateChat(shortPeerID)
|
||||||
context.privateChats.removeValue(forKey: shortPeerID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.unreadPrivateMessages.contains(shortPeerID) {
|
if hadUnread {
|
||||||
context.unreadPrivateMessages.remove(shortPeerID)
|
context.markPrivateChatRead(shortPeerID)
|
||||||
context.unreadPrivateMessages.insert(stablePeerID)
|
context.markPrivateChatUnread(stablePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
context.selectedPrivateChatPeer = stablePeerID
|
context.selectedPrivateChatPeer = stablePeerID
|
||||||
|
|||||||
@@ -119,9 +119,26 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// MARK: - Published Properties
|
// 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
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -164,13 +181,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@MainActor
|
@MainActor
|
||||||
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
|
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
|
||||||
@Published var allPeers: [BitchatPeer] = []
|
@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]] {
|
var privateChats: [PeerID: [BitchatMessage]] {
|
||||||
get { privateChatManager.privateChats }
|
conversations.directMessagesByRoutingPeerID()
|
||||||
set {
|
|
||||||
privateChatManager.privateChats = newValue
|
|
||||||
schedulePrivateConversationStoreSynchronization()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@MainActor
|
||||||
var selectedPrivateChatPeer: PeerID? {
|
var selectedPrivateChatPeer: PeerID? {
|
||||||
get { privateChatManager.selectedPeer }
|
get { privateChatManager.selectedPeer }
|
||||||
set {
|
set {
|
||||||
@@ -179,19 +204,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
} else {
|
} else {
|
||||||
privateChatManager.endChat()
|
privateChatManager.endChat()
|
||||||
}
|
}
|
||||||
synchronizePrivateConversationStore()
|
|
||||||
synchronizeConversationSelectionStore()
|
synchronizeConversationSelectionStore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Read-only derived view of the store's unread direct conversations.
|
||||||
|
/// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`.
|
||||||
|
@MainActor
|
||||||
var unreadPrivateMessages: Set<PeerID> {
|
var unreadPrivateMessages: Set<PeerID> {
|
||||||
get { privateChatManager.unreadMessages }
|
conversations.unreadDirectRoutingPeerIDs()
|
||||||
set {
|
|
||||||
privateChatManager.unreadMessages = newValue
|
|
||||||
schedulePrivateConversationStoreSynchronization()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if there are any unread messages (including from temporary Nostr peer IDs)
|
/// Check if there are any unread messages (including from temporary Nostr peer IDs)
|
||||||
|
@MainActor
|
||||||
var hasAnyUnreadMessages: Bool {
|
var hasAnyUnreadMessages: Bool {
|
||||||
!unreadPrivateMessages.isEmpty
|
!unreadPrivateMessages.isEmpty
|
||||||
}
|
}
|
||||||
@@ -270,8 +294,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
let meshService: Transport
|
let meshService: Transport
|
||||||
let idBridge: NostrIdentityBridge
|
let idBridge: NostrIdentityBridge
|
||||||
let identityManager: SecureIdentityStateManagerProtocol
|
let identityManager: SecureIdentityStateManagerProtocol
|
||||||
let conversationStore: ConversationStore
|
/// Single source of truth for conversation message state and selection
|
||||||
let identityResolver: IdentityResolver
|
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
|
||||||
|
/// through.
|
||||||
|
let conversations: ConversationStore
|
||||||
let peerIdentityStore: PeerIdentityStore
|
let peerIdentityStore: PeerIdentityStore
|
||||||
let locationPresenceStore: LocationPresenceStore
|
let locationPresenceStore: LocationPresenceStore
|
||||||
let locationManager: LocationChannelManager
|
let locationManager: LocationChannelManager
|
||||||
@@ -282,13 +308,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
// Location channel state (macOS supports manual geohash selection)
|
// Location channel state (macOS supports manual geohash selection)
|
||||||
var activeChannel: ChannelID {
|
var activeChannel: ChannelID {
|
||||||
get { conversationStore.activeChannel }
|
get { conversations.activeChannel }
|
||||||
set {
|
set {
|
||||||
guard conversationStore.activeChannel != newValue else { return }
|
guard conversations.activeChannel != newValue else { return }
|
||||||
publicMessagePipeline.updateActiveChannel(newValue)
|
conversations.setActiveChannel(newValue)
|
||||||
conversationStore.setActiveChannel(newValue)
|
visibleMessagesCache = nil
|
||||||
synchronizePublicConversationStore(for: newValue)
|
|
||||||
synchronizeConversationSelectionStore()
|
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,11 +363,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
|
||||||
var timelineStore = PublicTimelineStore(
|
|
||||||
meshCap: TransportConfig.meshTimelineCap,
|
|
||||||
geohashCap: TransportConfig.geoTimelineCap
|
|
||||||
)
|
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
@@ -374,7 +393,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// MARK: - Message Delivery Tracking
|
// MARK: - Message Delivery Tracking
|
||||||
|
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
private var pendingPrivateConversationStoreSyncTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
var transferIdToMessageIDs: [String: [String]] {
|
var transferIdToMessageIDs: [String: [String]] {
|
||||||
mediaTransferCoordinator.transferIdToMessageIDs
|
mediaTransferCoordinator.transferIdToMessageIDs
|
||||||
@@ -525,6 +543,185 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
selectedPrivateChatPeer = newPeerID
|
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
|
// MARK: - Initialization
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -532,21 +729,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
conversationStore: ConversationStore? = nil,
|
conversations: ConversationStore? = nil,
|
||||||
identityResolver: IdentityResolver? = nil,
|
|
||||||
peerIdentityStore: PeerIdentityStore? = nil,
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared
|
||||||
) {
|
) {
|
||||||
let conversationStore = conversationStore ?? ConversationStore()
|
|
||||||
let identityResolver = identityResolver ?? IdentityResolver()
|
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
|
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
|
||||||
conversationStore: conversationStore,
|
conversations: conversations,
|
||||||
identityResolver: identityResolver,
|
|
||||||
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
|
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
|
||||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||||
locationManager: locationManager
|
locationManager: locationManager
|
||||||
@@ -561,14 +754,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
conversationStore: ConversationStore? = nil,
|
conversations: ConversationStore? = nil,
|
||||||
identityResolver: IdentityResolver? = nil,
|
|
||||||
peerIdentityStore: PeerIdentityStore? = nil,
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared
|
||||||
) {
|
) {
|
||||||
let conversationStore = conversationStore ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let identityResolver = identityResolver ?? IdentityResolver()
|
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
|
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
|
||||||
let services = ChatViewModelServiceBundle(
|
let services = ChatViewModelServiceBundle(
|
||||||
@@ -581,8 +772,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
self.conversationStore = conversationStore
|
self.conversations = conversations
|
||||||
self.identityResolver = identityResolver
|
|
||||||
self.peerIdentityStore = peerIdentityStore
|
self.peerIdentityStore = peerIdentityStore
|
||||||
self.locationPresenceStore = locationPresenceStore
|
self.locationPresenceStore = locationPresenceStore
|
||||||
self.locationManager = locationManager
|
self.locationManager = locationManager
|
||||||
@@ -596,8 +786,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
self.publicMessagePipeline = services.publicMessagePipeline
|
self.publicMessagePipeline = services.publicMessagePipeline
|
||||||
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts()
|
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()
|
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||||
initializeConversationStore()
|
synchronizeConversationSelectionStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
// MARK: - Deinitialization
|
||||||
@@ -789,8 +995,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
recipientNickname: meshService.peerNickname(peerID: peerID),
|
recipientNickname: meshService.peerNickname(peerID: peerID),
|
||||||
senderPeerID: meshService.myPeerID
|
senderPeerID: meshService.myPeerID
|
||||||
)
|
)
|
||||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
appendPrivateMessage(systemMessage, to: peerID)
|
||||||
privateChats[peerID]?.append(systemMessage)
|
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,14 +1084,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
// Messages are processed immediately - nothing to flush
|
// Messages are processed immediately - nothing to flush
|
||||||
|
|
||||||
// Clear all messages
|
// Clear all messages (public timelines and private chats live in the
|
||||||
messages.removeAll()
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
timelineStore = PublicTimelineStore(
|
// the legacy mirror empty with it)
|
||||||
meshCap: TransportConfig.meshTimelineCap,
|
conversations.clearAll()
|
||||||
geohashCap: TransportConfig.geoTimelineCap
|
pendingGeohashSystemMessages.removeAll()
|
||||||
)
|
|
||||||
privateChatManager.privateChats.removeAll()
|
|
||||||
privateChatManager.unreadMessages.removeAll()
|
|
||||||
|
|
||||||
// Delete all keychain data (including Noise and Nostr keys)
|
// Delete all keychain data (including Noise and Nostr keys)
|
||||||
_ = keychain.deleteAllKeychainData()
|
_ = keychain.deleteAllKeychainData()
|
||||||
@@ -938,7 +1140,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
bleService.resetIdentityForPanic(currentNickname: nickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeConversationStore()
|
synchronizeConversationSelectionStore()
|
||||||
|
|
||||||
// No need to force UserDefaults synchronization
|
// No need to force UserDefaults synchronization
|
||||||
|
|
||||||
@@ -1060,64 +1262,41 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// MARK: - Message Handling
|
// MARK: - Message Handling
|
||||||
|
|
||||||
@MainActor
|
/// Pushes the private-chat manager's selection into the store, which
|
||||||
func initializeConversationStore() {
|
/// derives `selectedConversationID` from it and the active channel.
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func synchronizeConversationSelectionStore() {
|
func synchronizeConversationSelectionStore() {
|
||||||
conversationStore.setSelectedPeerID(
|
conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer)
|
||||||
privateChatManager.selectedPeer,
|
|
||||||
activeChannel: activeChannel,
|
|
||||||
identityResolver: identityResolver
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimMessagesIfNeeded() {
|
|
||||||
if messages.count > maxMessages {
|
|
||||||
messages = Array(messages.suffix(maxMessages))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
@MainActor
|
||||||
func refreshVisibleMessages(from channel: ChannelID? = nil) {
|
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
|
@MainActor
|
||||||
@@ -1159,15 +1338,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.clearCurrentPublicTimeline()
|
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
|
// MARK: - Peer Lookup Helpers
|
||||||
|
|
||||||
func getPeer(byID peerID: PeerID) -> BitchatPeer? {
|
func getPeer(byID peerID: PeerID) -> BitchatPeer? {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ final class ChatViewModelBootstrapper {
|
|||||||
|
|
||||||
private extension ChatViewModelBootstrapper {
|
private extension ChatViewModelBootstrapper {
|
||||||
func wireServiceGraph() {
|
func wireServiceGraph() {
|
||||||
|
viewModel.privateChatManager.conversationStore = viewModel.conversations
|
||||||
viewModel.privateChatManager.messageRouter = viewModel.messageRouter
|
viewModel.privateChatManager.messageRouter = viewModel.messageRouter
|
||||||
viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService
|
viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService
|
||||||
viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter
|
viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter
|
||||||
@@ -89,24 +90,9 @@ private extension ChatViewModelBootstrapper {
|
|||||||
}
|
}
|
||||||
.store(in: &viewModel.cancellables)
|
.store(in: &viewModel.cancellables)
|
||||||
|
|
||||||
viewModel.privateChatManager.$privateChats
|
// Private message state flows through the single-writer
|
||||||
.receive(on: DispatchQueue.main)
|
// `ConversationStore` intents and its `changes` subject; only the
|
||||||
.sink { [weak viewModel] _ in
|
// selection still originates in `PrivateChatManager`.
|
||||||
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)
|
|
||||||
|
|
||||||
viewModel.privateChatManager.$selectedPeer
|
viewModel.privateChatManager.$selectedPeer
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak viewModel] _ in
|
.sink { [weak viewModel] _ in
|
||||||
@@ -144,7 +130,6 @@ private extension ChatViewModelBootstrapper {
|
|||||||
viewModel.meshService.startServices()
|
viewModel.meshService.startServices()
|
||||||
|
|
||||||
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
|
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
|
||||||
viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
|
||||||
guard let viewModel,
|
guard let viewModel,
|
||||||
@@ -173,7 +158,6 @@ private extension ChatViewModelBootstrapper {
|
|||||||
guard let viewModel else { return }
|
guard let viewModel else { return }
|
||||||
|
|
||||||
viewModel.allPeers = peers
|
viewModel.allPeers = peers
|
||||||
viewModel.identityResolver.register(peers: peers)
|
|
||||||
|
|
||||||
var uniquePeers: [PeerID: BitchatPeer] = [:]
|
var uniquePeers: [PeerID: BitchatPeer] = [:]
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
@@ -192,7 +176,6 @@ private extension ChatViewModelBootstrapper {
|
|||||||
viewModel.updatePrivateChatPeerIfNeeded()
|
viewModel.updatePrivateChatPeerIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.synchronizePrivateConversationStore()
|
|
||||||
viewModel.synchronizeConversationSelectionStore()
|
viewModel.synchronizeConversationSelectionStore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ protocol GeoPresenceContext: AnyObject {
|
|||||||
func geoParticipantCount(for geohash: String) -> Int
|
func geoParticipantCount(for geohash: String) -> Int
|
||||||
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
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 appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||||
func synchronizePublicConversationStore(forGeohash geohash: String)
|
|
||||||
|
|
||||||
/// Posts the sampled-geohash-activity local notification.
|
/// Posts the sampled-geohash-activity local notification.
|
||||||
func notifyGeohashActivity(geohash: String, bodyPreview: String)
|
func notifyGeohashActivity(geohash: String, bodyPreview: String)
|
||||||
@@ -32,13 +34,10 @@ protocol GeoPresenceContext: AnyObject {
|
|||||||
|
|
||||||
extension ChatViewModel: GeoPresenceContext {
|
extension ChatViewModel: GeoPresenceContext {
|
||||||
// `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr
|
// `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr
|
||||||
// identity/blocking members, and `synchronizePublicConversationStore`
|
// identity/blocking members, and the
|
||||||
// already have witnesses on `ChatViewModel`. The members below flatten
|
// `appendGeohashMessageIfAbsent(_:toGeohash:)` store intent already have
|
||||||
// nested service accesses into intent-named calls.
|
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var teleportedGeoCount: Int {
|
var teleportedGeoCount: Int {
|
||||||
locationPresenceStore.teleportedGeo.count
|
locationPresenceStore.teleportedGeo.count
|
||||||
@@ -166,7 +165,6 @@ final class GeoPresenceTracker {
|
|||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
)
|
)
|
||||||
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
|
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
|
||||||
context.synchronizePublicConversationStore(forGeohash: gh)
|
|
||||||
context.notifyGeohashActivity(geohash: gh, bodyPreview: preview)
|
context.notifyGeohashActivity(geohash: gh, bodyPreview: preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ protocol GeohashSubscriptionContext: AnyObject {
|
|||||||
|
|
||||||
// MARK: Public timeline & pipeline
|
// MARK: Public timeline & pipeline
|
||||||
var messages: [BitchatMessage] { get }
|
var messages: [BitchatMessage] { get }
|
||||||
func resetPublicMessagePipeline()
|
/// Commits any batched-but-unflushed public messages to the store so a
|
||||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID)
|
/// channel switch never strands them in the pipeline buffer.
|
||||||
|
func flushPublicMessagePipeline()
|
||||||
func refreshVisibleMessages(from channel: ChannelID?)
|
func refreshVisibleMessages(from channel: ChannelID?)
|
||||||
func addPublicSystemMessage(_ content: String)
|
func addPublicSystemMessage(_ content: String)
|
||||||
func drainPendingGeohashSystemMessages() -> [String]
|
func drainPendingGeohashSystemMessages() -> [String]
|
||||||
@@ -64,16 +65,8 @@ extension ChatViewModel: GeohashSubscriptionContext {
|
|||||||
// `ChatViewModel`. The members below flatten nested service accesses into
|
// `ChatViewModel`. The members below flatten nested service accesses into
|
||||||
// intent-named calls.
|
// intent-named calls.
|
||||||
|
|
||||||
func resetPublicMessagePipeline() {
|
func flushPublicMessagePipeline() {
|
||||||
publicMessagePipeline.reset()
|
publicMessagePipeline.flushIfNeeded()
|
||||||
}
|
|
||||||
|
|
||||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {
|
|
||||||
publicMessagePipeline.updateActiveChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func drainPendingGeohashSystemMessages() -> [String] {
|
|
||||||
timelineStore.drainPendingGeohashSystemMessages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearProcessedNostrEvents() {
|
func clearProcessedNostrEvents() {
|
||||||
@@ -178,9 +171,8 @@ final class GeohashSubscriptionManager {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
func switchLocationChannel(to channel: ChannelID) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
context.resetPublicMessagePipeline()
|
context.flushPublicMessagePipeline()
|
||||||
context.activeChannel = channel
|
context.activeChannel = channel
|
||||||
context.updatePublicMessagePipelineChannel(channel)
|
|
||||||
|
|
||||||
context.clearProcessedNostrEvents()
|
context.clearProcessedNostrEvents()
|
||||||
switch channel {
|
switch channel {
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
// PublicMessagePipeline.swift
|
// PublicMessagePipeline.swift
|
||||||
// bitchat
|
// 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
|
import BitFoundation
|
||||||
@@ -10,12 +14,13 @@ import Foundation
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol PublicMessagePipelineDelegate: AnyObject {
|
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, normalizeContent content: String) -> String
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
|
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: 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 pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
|
||||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
|
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
|
||||||
}
|
}
|
||||||
@@ -24,14 +29,13 @@ protocol PublicMessagePipelineDelegate: AnyObject {
|
|||||||
final class PublicMessagePipeline {
|
final class PublicMessagePipeline {
|
||||||
weak var delegate: PublicMessagePipelineDelegate?
|
weak var delegate: PublicMessagePipelineDelegate?
|
||||||
|
|
||||||
private var buffer: [BitchatMessage] = []
|
private var buffer: [(message: BitchatMessage, conversationID: ConversationID)] = []
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private let baseFlushInterval: TimeInterval
|
private let baseFlushInterval: TimeInterval
|
||||||
private var dynamicFlushInterval: TimeInterval
|
private var dynamicFlushInterval: TimeInterval
|
||||||
private var recentBatchSizes: [Int] = []
|
private var recentBatchSizes: [Int] = []
|
||||||
private let maxRecentBatchSamples: Int
|
private let maxRecentBatchSamples: Int
|
||||||
private let dedupWindow: TimeInterval
|
private let dedupWindow: TimeInterval
|
||||||
private var activeChannel: ChannelID = .mesh
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
|
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
|
||||||
@@ -48,25 +52,17 @@ final class PublicMessagePipeline {
|
|||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateActiveChannel(_ channel: ChannelID) {
|
/// Buffers a message destined for `conversationID`; the next batched
|
||||||
activeChannel = channel
|
/// 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) {
|
||||||
func enqueue(_ message: BitchatMessage) {
|
buffer.append((message, conversationID))
|
||||||
buffer.append(message)
|
|
||||||
scheduleFlush()
|
scheduleFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushIfNeeded() {
|
func flushIfNeeded() {
|
||||||
flushBuffer()
|
flushBuffer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PublicMessagePipeline {
|
private extension PublicMessagePipeline {
|
||||||
@@ -91,57 +87,38 @@ private extension PublicMessagePipeline {
|
|||||||
|
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: true)
|
delegate.pipelineSetBatchingState(self, isBatching: true)
|
||||||
|
|
||||||
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
|
// Content-window dedup against recorded keys and within the batch;
|
||||||
var pending: [(message: BitchatMessage, contentKey: String)] = []
|
// ID dedup happens in the store at commit time.
|
||||||
|
var pending: [(message: BitchatMessage, conversationID: ConversationID, contentKey: String)] = []
|
||||||
var batchContentLatest: [String: Date] = [:]
|
var batchContentLatest: [String: Date] = [:]
|
||||||
|
|
||||||
for message in buffer {
|
for item in buffer {
|
||||||
if existingIDs.contains(message.id) { continue }
|
let contentKey = delegate.pipeline(self, normalizeContent: item.message.content)
|
||||||
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
|
|
||||||
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
|
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
|
||||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if let ts = batchContentLatest[contentKey],
|
if let ts = batchContentLatest[contentKey],
|
||||||
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
|
abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
existingIDs.insert(message.id)
|
pending.append((item.message, item.conversationID, contentKey))
|
||||||
pending.append((message, contentKey))
|
batchContentLatest[contentKey] = item.message.timestamp
|
||||||
batchContentLatest[contentKey] = message.timestamp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.removeAll(keepingCapacity: true)
|
buffer.removeAll(keepingCapacity: true)
|
||||||
guard !pending.isEmpty else {
|
guard !pending.isEmpty else {
|
||||||
delegate.pipelineSetBatchingState(self, isBatching: false)
|
delegate.pipelineSetBatchingState(self, isBatching: false)
|
||||||
if !buffer.isEmpty { scheduleFlush() }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.sort { $0.message.timestamp < $1.message.timestamp }
|
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 {
|
for item in pending {
|
||||||
let message = item.message
|
guard delegate.pipeline(self, commit: item.message, to: item.conversationID) else { continue }
|
||||||
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
|
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: item.message.timestamp)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delegate.pipeline(self, setMessages: messages)
|
|
||||||
delegate.pipelineTrimMessages(self)
|
|
||||||
|
|
||||||
updateFlushInterval(withBatchSize: pending.count)
|
updateFlushInterval(withBatchSize: pending.count)
|
||||||
|
|
||||||
for item in pending {
|
for item in pending {
|
||||||
@@ -165,27 +142,4 @@ private extension PublicMessagePipeline {
|
|||||||
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
||||||
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
|
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(
|
let privateConversationModel = PrivateConversationModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: viewModel.conversationStore
|
conversations: viewModel.conversations
|
||||||
)
|
)
|
||||||
let locationChannelsModel = LocationChannelsModel()
|
let locationChannelsModel = LocationChannelsModel()
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,12 @@ struct TextMessageView: View {
|
|||||||
)
|
)
|
||||||
let privateConversationModel = PrivateConversationModel(
|
let privateConversationModel = PrivateConversationModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: viewModel.conversationStore
|
conversations: viewModel.conversations
|
||||||
)
|
)
|
||||||
let conversationUIModel = ConversationUIModel(
|
let conversationUIModel = ConversationUIModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
privateConversationModel: privateConversationModel,
|
privateConversationModel: privateConversationModel,
|
||||||
conversationStore: viewModel.conversationStore
|
conversations: viewModel.conversations
|
||||||
)
|
)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -45,6 +46,27 @@ private func makeArchitectureSnapshot(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeArchitectureMessage(
|
||||||
|
id: String,
|
||||||
|
timestamp: TimeInterval = 0,
|
||||||
|
content: String? = nil,
|
||||||
|
isPrivate: Bool = false,
|
||||||
|
senderPeerID: PeerID = PeerID(str: "peer-a")
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: "alice",
|
||||||
|
content: content ?? "message \(id)",
|
||||||
|
timestamp: Date(timeIntervalSince1970: timestamp),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
recipientNickname: isPrivate ? "builder" : nil,
|
||||||
|
senderPeerID: senderPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeoutNanoseconds: UInt64 = 3_000_000_000,
|
timeoutNanoseconds: UInt64 = 3_000_000_000,
|
||||||
@@ -127,251 +149,119 @@ struct AppArchitectureTests {
|
|||||||
|
|
||||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||||
let first = PeerHandle(
|
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||||
id: "noise:abc123",
|
let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b"))
|
||||||
routingPeerID: PeerID(str: "peer-a"),
|
|
||||||
displayName: "alice",
|
|
||||||
noisePublicKeyHex: "abc123",
|
|
||||||
nostrPublicKey: nil
|
|
||||||
)
|
|
||||||
let second = PeerHandle(
|
|
||||||
id: "noise:abc123",
|
|
||||||
routingPeerID: PeerID(str: "peer-b"),
|
|
||||||
displayName: "alice-renamed",
|
|
||||||
noisePublicKeyHex: nil,
|
|
||||||
nostrPublicKey: "npub123"
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(first == second)
|
#expect(first == second)
|
||||||
#expect(Set([first, second]).count == 1)
|
#expect(Set([first, second]).count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore normalizes timeline ordering and duplicates")
|
@Test("ConversationStore orders timelines and replaces duplicates by message ID")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreNormalizesMessages() {
|
func conversationStoreOrdersAndDedupsMessages() {
|
||||||
let store = ConversationStore()
|
let store = ConversationStore()
|
||||||
let older = BitchatMessage(
|
let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first")
|
||||||
id: "m1",
|
let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second")
|
||||||
sender: "alice",
|
let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated")
|
||||||
content: "first",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 1),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: PeerID(str: "peer-a")
|
|
||||||
)
|
|
||||||
let newer = BitchatMessage(
|
|
||||||
id: "m2",
|
|
||||||
sender: "alice",
|
|
||||||
content: "second",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 2),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: PeerID(str: "peer-a")
|
|
||||||
)
|
|
||||||
let replacement = BitchatMessage(
|
|
||||||
id: "m2",
|
|
||||||
sender: "alice",
|
|
||||||
content: "second-updated",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 2),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: false,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: PeerID(str: "peer-a")
|
|
||||||
)
|
|
||||||
|
|
||||||
store.replaceMessages([newer, older, replacement], for: ConversationID.mesh)
|
store.append(newer, to: .mesh)
|
||||||
|
store.append(older, to: .mesh)
|
||||||
|
store.upsertByID(replacement, in: .mesh)
|
||||||
|
|
||||||
let messages = store.messages(for: ConversationID.mesh)
|
let messages = store.conversation(for: .mesh).messages
|
||||||
#expect(messages.map(\.id) == ["m1", "m2"])
|
#expect(messages.map(\.id) == ["m1", "m2"])
|
||||||
#expect(messages.last?.content == "second-updated")
|
#expect(messages.last?.content == "second-updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore tracks unread direct conversations with canonical IDs")
|
@Test("ConversationStore tracks unread direct conversations by routing peer ID")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreTracksUnreadDirectConversations() {
|
func conversationStoreTracksUnreadDirectConversations() {
|
||||||
let store = ConversationStore()
|
let store = ConversationStore()
|
||||||
let resolver = IdentityResolver()
|
|
||||||
let peerID = PeerID(str: "peer-1")
|
let peerID = PeerID(str: "peer-1")
|
||||||
let message = BitchatMessage(
|
let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID)
|
||||||
id: "dm-1",
|
|
||||||
sender: "alice",
|
|
||||||
content: "hello",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "bob",
|
|
||||||
senderPeerID: peerID
|
|
||||||
)
|
|
||||||
|
|
||||||
store.synchronizePrivateChats(
|
store.append(message, to: .directPeer(peerID))
|
||||||
[peerID: [message]],
|
store.markUnread(.directPeer(peerID))
|
||||||
unreadPeerIDs: Set([peerID]),
|
|
||||||
identityResolver: resolver
|
|
||||||
)
|
|
||||||
|
|
||||||
let conversationID = ConversationID.direct(
|
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"])
|
||||||
resolver.canonicalHandle(for: peerID, displayName: "alice")
|
#expect(store.unreadDirectRoutingPeerIDs() == Set([peerID]))
|
||||||
)
|
#expect(store.conversation(for: .directPeer(peerID)).isUnread)
|
||||||
|
|
||||||
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"])
|
store.markRead(.directPeer(peerID))
|
||||||
#expect(store.unreadConversations.contains(conversationID))
|
#expect(store.unreadDirectRoutingPeerIDs().isEmpty)
|
||||||
|
#expect(!store.conversation(for: .directPeer(peerID)).isUnread)
|
||||||
store.markRead(conversationID)
|
|
||||||
#expect(!store.unreadConversations.contains(conversationID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore tracks the selected app conversation context")
|
@Test("ConversationStore derives the selected conversation from channel and private peer")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreTracksSelectedConversationContext() {
|
func conversationStoreTracksSelectedConversationContext() {
|
||||||
let store = ConversationStore()
|
let store = ConversationStore()
|
||||||
let resolver = IdentityResolver()
|
let peerID = PeerID(str: "0011223344556677")
|
||||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
|
||||||
let shortPeerID = PeerID(str: "0011223344556677")
|
|
||||||
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
||||||
let peer = BitchatPeer(
|
|
||||||
peerID: shortPeerID,
|
|
||||||
noisePublicKey: noiseKey,
|
|
||||||
nickname: "alice",
|
|
||||||
isConnected: true,
|
|
||||||
isReachable: true
|
|
||||||
)
|
|
||||||
|
|
||||||
resolver.register(peers: [peer])
|
store.setActiveChannel(geohashChannel)
|
||||||
store.synchronizeSelection(
|
store.setSelectedPrivatePeer(peerID)
|
||||||
activeChannel: geohashChannel,
|
|
||||||
selectedPeerID: shortPeerID,
|
|
||||||
identityResolver: resolver
|
|
||||||
)
|
|
||||||
|
|
||||||
let expectedConversationID = ConversationID.direct(
|
|
||||||
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(store.activeChannel == geohashChannel)
|
#expect(store.activeChannel == geohashChannel)
|
||||||
#expect(store.selectedPrivatePeerID == shortPeerID)
|
#expect(store.selectedPrivatePeerID == peerID)
|
||||||
#expect(store.selectedConversationID == expectedConversationID)
|
// The open private chat wins the derived selection.
|
||||||
|
#expect(store.selectedConversationID == ConversationID.directPeer(peerID))
|
||||||
|
|
||||||
store.synchronizeSelection(
|
store.setSelectedPrivatePeer(nil)
|
||||||
activeChannel: ChannelID.mesh,
|
// Selection falls back to the active public channel.
|
||||||
selectedPeerID: nil,
|
#expect(store.selectedConversationID == ConversationID(channelID: geohashChannel))
|
||||||
identityResolver: resolver
|
|
||||||
)
|
|
||||||
|
|
||||||
|
store.setActiveChannel(.mesh)
|
||||||
#expect(store.activeChannel == ChannelID.mesh)
|
#expect(store.activeChannel == ChannelID.mesh)
|
||||||
#expect(store.selectedPrivatePeerID == nil)
|
#expect(store.selectedPrivatePeerID == nil)
|
||||||
#expect(store.selectedConversationID == ConversationID.mesh)
|
#expect(store.selectedConversationID == ConversationID.mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ConversationStore exposes direct conversations by the latest routing peer ID")
|
@Test("ConversationStore re-keys a direct conversation via the migrate intent")
|
||||||
@MainActor
|
@MainActor
|
||||||
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
|
func conversationStoreMigratesDirectConversationsBetweenPeerIDs() {
|
||||||
let store = ConversationStore()
|
let store = ConversationStore()
|
||||||
let resolver = IdentityResolver()
|
|
||||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||||
let shortPeerID = PeerID(str: "0011223344556677")
|
let shortPeerID = PeerID(str: "0011223344556677")
|
||||||
let fullPeerID = PeerID(hexData: noiseKey)
|
let fullPeerID = PeerID(hexData: noiseKey)
|
||||||
let firstMessage = BitchatMessage(
|
|
||||||
id: "dm-1",
|
|
||||||
sender: "alice",
|
|
||||||
content: "short id",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 1),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "builder",
|
|
||||||
senderPeerID: shortPeerID
|
|
||||||
)
|
|
||||||
let secondMessage = BitchatMessage(
|
|
||||||
id: "dm-2",
|
|
||||||
sender: "alice",
|
|
||||||
content: "full id",
|
|
||||||
timestamp: Date(timeIntervalSince1970: 2),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "builder",
|
|
||||||
senderPeerID: fullPeerID
|
|
||||||
)
|
|
||||||
|
|
||||||
resolver.register(
|
store.append(
|
||||||
peer: BitchatPeer(
|
makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID),
|
||||||
peerID: shortPeerID,
|
to: .directPeer(shortPeerID)
|
||||||
noisePublicKey: noiseKey,
|
|
||||||
nickname: "alice",
|
|
||||||
isConnected: true,
|
|
||||||
isReachable: true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
store.synchronizePrivateChats(
|
|
||||||
[shortPeerID: [firstMessage]],
|
|
||||||
unreadPeerIDs: Set([shortPeerID]),
|
|
||||||
identityResolver: resolver
|
|
||||||
)
|
)
|
||||||
|
store.markUnread(.directPeer(shortPeerID))
|
||||||
|
store.setSelectedPrivatePeer(shortPeerID)
|
||||||
|
|
||||||
resolver.register(
|
store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID))
|
||||||
peer: BitchatPeer(
|
|
||||||
peerID: fullPeerID,
|
|
||||||
noisePublicKey: noiseKey,
|
|
||||||
nickname: "alice",
|
|
||||||
isConnected: true,
|
|
||||||
isReachable: true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
store.synchronizePrivateChats(
|
|
||||||
[fullPeerID: [secondMessage]],
|
|
||||||
unreadPeerIDs: Set([fullPeerID]),
|
|
||||||
identityResolver: resolver
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID]))
|
// Raw keying: the old peer's conversation is gone, the new peer's
|
||||||
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"])
|
// conversation holds the timeline, unread and selection carried over.
|
||||||
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
|
#expect(store.conversationsByID[.directPeer(shortPeerID)] == nil)
|
||||||
|
#expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID]))
|
||||||
|
#expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"])
|
||||||
|
#expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID]))
|
||||||
|
#expect(store.selectedPrivatePeerID == fullPeerID)
|
||||||
|
#expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("PrivateInboxModel mirrors direct message state from ConversationStore")
|
@Test("PrivateInboxModel reads direct message state from the ConversationStore")
|
||||||
@MainActor
|
@MainActor
|
||||||
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
|
func privateInboxModelReadsDirectMessageStateFromConversationStore() {
|
||||||
let store = ConversationStore()
|
let store = ConversationStore()
|
||||||
let resolver = IdentityResolver()
|
let inboxModel = PrivateInboxModel(conversations: store)
|
||||||
let inboxModel = PrivateInboxModel(conversationStore: store)
|
|
||||||
let messagePeerID = PeerID(str: "peer-1")
|
let messagePeerID = PeerID(str: "peer-1")
|
||||||
let unreadOnlyPeerID = PeerID(str: "peer-2")
|
let unreadOnlyPeerID = PeerID(str: "peer-2")
|
||||||
let selectedOnlyPeerID = PeerID(str: "peer-3")
|
let selectedOnlyPeerID = PeerID(str: "peer-3")
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "dm-1",
|
|
||||||
sender: "alice",
|
|
||||||
content: "hello",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: "builder",
|
|
||||||
senderPeerID: messagePeerID
|
|
||||||
)
|
|
||||||
|
|
||||||
store.synchronizePrivateChats(
|
store.append(
|
||||||
[messagePeerID: [message]],
|
makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID),
|
||||||
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]),
|
to: .directPeer(messagePeerID)
|
||||||
identityResolver: resolver
|
|
||||||
)
|
|
||||||
store.synchronizeSelection(
|
|
||||||
activeChannel: ChannelID.mesh,
|
|
||||||
selectedPeerID: selectedOnlyPeerID,
|
|
||||||
identityResolver: resolver
|
|
||||||
)
|
)
|
||||||
|
store.markUnread(.directPeer(messagePeerID))
|
||||||
|
store.markUnread(.directPeer(unreadOnlyPeerID))
|
||||||
|
store.setSelectedPrivatePeer(selectedOnlyPeerID)
|
||||||
|
|
||||||
await waitUntil {
|
// Reads are synchronous against the single-writer store.
|
||||||
inboxModel.selectedPeerID == selectedOnlyPeerID &&
|
|
||||||
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
|
|
||||||
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
|
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
|
||||||
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
|
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
|
||||||
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
|
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
|
||||||
@@ -379,13 +269,74 @@ struct AppArchitectureTests {
|
|||||||
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
|
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("PrivateInboxModel republishes only for the selected conversation")
|
||||||
|
@MainActor
|
||||||
|
func privateInboxModelIsolatesBackgroundConversations() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let inboxModel = PrivateInboxModel(conversations: store)
|
||||||
|
let selectedPeerID = PeerID(str: "peer-selected")
|
||||||
|
let backgroundPeerID = PeerID(str: "peer-background")
|
||||||
|
store.setSelectedPrivatePeer(selectedPeerID)
|
||||||
|
|
||||||
|
var emissions = 0
|
||||||
|
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
let baseline = emissions
|
||||||
|
store.append(
|
||||||
|
makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID),
|
||||||
|
to: .directPeer(backgroundPeerID)
|
||||||
|
)
|
||||||
|
// An append to a background chat does not republish the model.
|
||||||
|
#expect(emissions == baseline)
|
||||||
|
|
||||||
|
store.append(
|
||||||
|
makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID),
|
||||||
|
to: .directPeer(selectedPeerID)
|
||||||
|
)
|
||||||
|
#expect(emissions == baseline + 1)
|
||||||
|
#expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PublicChatModel ignores appends to background conversations")
|
||||||
|
@MainActor
|
||||||
|
func publicChatModelIsolatesBackgroundConversations() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
store.setActiveChannel(.mesh)
|
||||||
|
let model = PublicChatModel(conversations: store)
|
||||||
|
|
||||||
|
var emissions = 0
|
||||||
|
let cancellable = model.objectWillChange.sink { _ in emissions += 1 }
|
||||||
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
|
store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh)
|
||||||
|
let afterActiveAppend = emissions
|
||||||
|
#expect(afterActiveAppend >= 1)
|
||||||
|
#expect(model.messages.map(\.id) == ["mesh-1"])
|
||||||
|
|
||||||
|
// Appends to a background geohash channel and to a private chat do
|
||||||
|
// not invalidate the observer of the active conversation.
|
||||||
|
store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd"))
|
||||||
|
store.append(
|
||||||
|
makeArchitectureMessage(id: "dm-1", isPrivate: true),
|
||||||
|
to: .directPeer(PeerID(str: "peer-1"))
|
||||||
|
)
|
||||||
|
#expect(emissions == afterActiveAppend)
|
||||||
|
#expect(model.messages.map(\.id) == ["mesh-1"])
|
||||||
|
|
||||||
|
// Switching the channel retargets the observation.
|
||||||
|
store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")))
|
||||||
|
#expect(model.messages.map(\.id) == ["geo-1"])
|
||||||
|
store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd"))
|
||||||
|
#expect(model.messages.map(\.id) == ["geo-1", "geo-2"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test("AppChromeModel mirrors nickname and unread state through focused models")
|
@Test("AppChromeModel mirrors nickname and unread state through focused models")
|
||||||
@MainActor
|
@MainActor
|
||||||
func appChromeModelMirrorsNicknameAndUnreadState() async {
|
func appChromeModelMirrorsNicknameAndUnreadState() async {
|
||||||
let viewModel = makeArchitectureViewModel()
|
let viewModel = makeArchitectureViewModel()
|
||||||
let conversationStore = ConversationStore()
|
let conversations = ConversationStore()
|
||||||
let resolver = IdentityResolver()
|
let privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
|
||||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||||
|
|
||||||
chromeModel.setNickname("builder")
|
chromeModel.setNickname("builder")
|
||||||
@@ -398,11 +349,7 @@ struct AppArchitectureTests {
|
|||||||
#expect(!chromeModel.hasUnreadPrivateMessages)
|
#expect(!chromeModel.hasUnreadPrivateMessages)
|
||||||
|
|
||||||
let peerID = PeerID(str: "peer-1")
|
let peerID = PeerID(str: "peer-1")
|
||||||
conversationStore.synchronizePrivateChats(
|
conversations.markUnread(.directPeer(peerID))
|
||||||
[:],
|
|
||||||
unreadPeerIDs: Set([peerID]),
|
|
||||||
identityResolver: resolver
|
|
||||||
)
|
|
||||||
await waitUntil {
|
await waitUntil {
|
||||||
chromeModel.hasUnreadPrivateMessages
|
chromeModel.hasUnreadPrivateMessages
|
||||||
}
|
}
|
||||||
@@ -414,8 +361,7 @@ struct AppArchitectureTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func appChromeModelOwnsPresentationState() {
|
func appChromeModelOwnsPresentationState() {
|
||||||
let viewModel = makeArchitectureViewModel()
|
let viewModel = makeArchitectureViewModel()
|
||||||
let conversationStore = ConversationStore()
|
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
|
||||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
|
||||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||||
let peerID = PeerID(str: "peer-2")
|
let peerID = PeerID(str: "peer-2")
|
||||||
|
|
||||||
@@ -441,11 +387,10 @@ struct AppArchitectureTests {
|
|||||||
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let conversationStore = viewModel.conversationStore
|
|
||||||
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||||
let conversationModel = PrivateConversationModel(
|
let conversationModel = PrivateConversationModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: viewModel.conversations,
|
||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,18 +438,17 @@ struct AppArchitectureTests {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let conversationStore = viewModel.conversationStore
|
|
||||||
locationManager.select(.mesh)
|
locationManager.select(.mesh)
|
||||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||||
let privateConversationModel = PrivateConversationModel(
|
let privateConversationModel = PrivateConversationModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: viewModel.conversations,
|
||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
let uiModel = ConversationUIModel(
|
let uiModel = ConversationUIModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
privateConversationModel: privateConversationModel,
|
privateConversationModel: privateConversationModel,
|
||||||
conversationStore: conversationStore
|
conversations: viewModel.conversations
|
||||||
)
|
)
|
||||||
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
||||||
defer {
|
defer {
|
||||||
@@ -558,11 +502,10 @@ struct AppArchitectureTests {
|
|||||||
|
|
||||||
let peerID = PeerID(str: "0011223344556677")
|
let peerID = PeerID(str: "0011223344556677")
|
||||||
let fingerprint = "verified-fingerprint"
|
let fingerprint = "verified-fingerprint"
|
||||||
let conversationStore = viewModel.conversationStore
|
|
||||||
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||||
let privateConversationModel = PrivateConversationModel(
|
let privateConversationModel = PrivateConversationModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: viewModel.conversations,
|
||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
let verificationModel = VerificationModel(
|
let verificationModel = VerificationModel(
|
||||||
@@ -631,7 +574,7 @@ struct AppArchitectureTests {
|
|||||||
transport.reachablePeers.insert(otherPeerID)
|
transport.reachablePeers.insert(otherPeerID)
|
||||||
viewModel.nickname = "builder"
|
viewModel.nickname = "builder"
|
||||||
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
|
||||||
viewModel.unreadPrivateMessages = Set([otherPeerID])
|
viewModel.markPrivateChatUnread(otherPeerID)
|
||||||
transport.updatePeerSnapshots([
|
transport.updatePeerSnapshots([
|
||||||
makeArchitectureSnapshot(
|
makeArchitectureSnapshot(
|
||||||
peerID: myPeerID,
|
peerID: myPeerID,
|
||||||
@@ -664,7 +607,7 @@ struct AppArchitectureTests {
|
|||||||
|
|
||||||
let peerListModel = PeerListModel(
|
let peerListModel = PeerListModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: viewModel.conversationStore,
|
conversations: viewModel.conversations,
|
||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
|||||||
// Chat & receipt state
|
// Chat & receipt state
|
||||||
var messages: [BitchatMessage] = []
|
var messages: [BitchatMessage] = []
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
privateChats[peerID] ?? []
|
||||||
|
}
|
||||||
var unreadPrivateMessages: Set<PeerID> = []
|
var unreadPrivateMessages: Set<PeerID> = []
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
var sentReadReceipts: Set<String> = []
|
var sentReadReceipts: Set<String> = []
|
||||||
@@ -38,9 +42,23 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
|||||||
var nostrKeyMapping: [PeerID: String] = [:]
|
var nostrKeyMapping: [PeerID: String] = [:]
|
||||||
private(set) var ownerLevelReadPasses: [PeerID] = []
|
private(set) var ownerLevelReadPasses: [PeerID] = []
|
||||||
private(set) var managerReadMarks: [PeerID] = []
|
private(set) var managerReadMarks: [PeerID] = []
|
||||||
private(set) var privateStoreSyncCount = 0
|
|
||||||
private(set) var systemMessages: [String] = []
|
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
|
@discardableResult
|
||||||
func markReadReceiptSent(_ messageID: String) -> Bool {
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||||
sentReadReceipts.insert(messageID).inserted
|
sentReadReceipts.insert(messageID).inserted
|
||||||
@@ -61,7 +79,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
|||||||
work()
|
work()
|
||||||
}
|
}
|
||||||
|
|
||||||
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
|
|
||||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||||
|
|
||||||
// Peers & sessions
|
// Peers & sessions
|
||||||
@@ -234,7 +251,6 @@ struct ChatLifecycleCoordinatorContextTests {
|
|||||||
coordinator.markPrivateMessagesAsRead(from: convKey)
|
coordinator.markPrivateMessagesAsRead(from: convKey)
|
||||||
|
|
||||||
#expect(context.managerReadMarks == [convKey])
|
#expect(context.managerReadMarks == [convKey])
|
||||||
#expect(context.privateStoreSyncCount == 1)
|
|
||||||
// Only the peer's own un-acked, non-relay message gets a READ.
|
// Only the peer's own un-acked, non-relay message gets a READ.
|
||||||
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
|
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
|
||||||
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
|
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
|
||||||
|
|||||||
@@ -42,23 +42,27 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
|
|
||||||
// Message state
|
// Message state
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
var meshTimeline: [BitchatMessage] = []
|
|
||||||
private(set) var refreshedChannels: [ChannelID?] = []
|
@discardableResult
|
||||||
private(set) var trimCount = 0
|
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 removedMessages: [(messageID: String, cleanupFile: Bool)] = []
|
||||||
private(set) var systemMessages: [String] = []
|
private(set) var systemMessages: [String] = []
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
|
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
|
@discardableResult
|
||||||
meshTimeline.append(message)
|
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) {
|
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
||||||
removedMessages.append((messageID, cleanupFile))
|
removedMessages.append((messageID, cleanupFile))
|
||||||
}
|
}
|
||||||
@@ -119,22 +123,21 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(message.senderPeerID == context.myPeerID)
|
#expect(message.senderPeerID == context.myPeerID)
|
||||||
#expect(message.deliveryStatus == .sending)
|
#expect(message.deliveryStatus == .sending)
|
||||||
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
|
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
|
||||||
#expect(context.trimCount == 1)
|
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
#expect(context.meshTimeline.isEmpty)
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async {
|
func enqueueMediaMessage_publicAppendsToActiveConversation() async {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
|
||||||
let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil)
|
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.isPrivate)
|
||||||
#expect(message.sender == "me")
|
#expect(message.sender == "me")
|
||||||
#expect(context.refreshedChannels.count == 1)
|
|
||||||
#expect(context.privateChats.isEmpty)
|
#expect(context.privateChats.isEmpty)
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
}
|
}
|
||||||
@@ -200,7 +203,7 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
|
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
|
||||||
#expect(context.privateChats.isEmpty)
|
#expect(context.privateChats.isEmpty)
|
||||||
#expect(context.meshTimeline.isEmpty)
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,16 +42,13 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
|
|
||||||
// Public timeline & pipeline
|
// Public timeline & pipeline
|
||||||
var messages: [BitchatMessage] = []
|
var messages: [BitchatMessage] = []
|
||||||
private(set) var pipelineResetCount = 0
|
private(set) var pipelineFlushCount = 0
|
||||||
private(set) var pipelineChannelUpdates: [ChannelID] = []
|
|
||||||
private(set) var refreshedChannels: [ChannelID?] = []
|
private(set) var refreshedChannels: [ChannelID?] = []
|
||||||
private(set) var publicSystemMessages: [String] = []
|
private(set) var publicSystemMessages: [String] = []
|
||||||
var pendingGeohashSystemMessages: [String] = []
|
var pendingGeohashSystemMessages: [String] = []
|
||||||
private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = []
|
private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = []
|
||||||
private(set) var synchronizedGeohashes: [String] = []
|
|
||||||
|
|
||||||
func resetPublicMessagePipeline() { pipelineResetCount += 1 }
|
func flushPublicMessagePipeline() { pipelineFlushCount += 1 }
|
||||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) { pipelineChannelUpdates.append(channel) }
|
|
||||||
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
|
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
|
||||||
func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) }
|
func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) }
|
||||||
|
|
||||||
@@ -68,8 +65,6 @@ private final class MockChatNostrContext: ChatNostrContext {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func synchronizePublicConversationStore(forGeohash geohash: String) { synchronizedGeohashes.append(geohash) }
|
|
||||||
|
|
||||||
// Inbound public messages
|
// Inbound public messages
|
||||||
private(set) var handledPublicMessages: [BitchatMessage] = []
|
private(set) var handledPublicMessages: [BitchatMessage] = []
|
||||||
private(set) var mentionCheckedMessageIDs: [String] = []
|
private(set) var mentionCheckedMessageIDs: [String] = []
|
||||||
@@ -441,9 +436,8 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
|
|
||||||
coordinator.subscriptions.switchLocationChannel(to: .mesh)
|
coordinator.subscriptions.switchLocationChannel(to: .mesh)
|
||||||
|
|
||||||
#expect(context.pipelineResetCount == 1)
|
#expect(context.pipelineFlushCount == 1)
|
||||||
#expect(context.activeChannel == .mesh)
|
#expect(context.activeChannel == .mesh)
|
||||||
#expect(context.pipelineChannelUpdates == [.mesh])
|
|
||||||
#expect(context.clearProcessedNostrEventsCount == 1)
|
#expect(context.clearProcessedNostrEventsCount == 1)
|
||||||
#expect(context.refreshedChannels == [.mesh])
|
#expect(context.refreshedChannels == [.mesh])
|
||||||
#expect(context.refreshTimerStopCount == 1)
|
#expect(context.refreshTimerStopCount == 1)
|
||||||
@@ -578,7 +572,6 @@ struct GeoPresenceTrackerTests {
|
|||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
#expect(context.appendedGeohashMessages.isEmpty)
|
#expect(context.appendedGeohashMessages.isEmpty)
|
||||||
#expect(context.lastGeoNotificationAt["9q8yy"] == recent)
|
#expect(context.lastGeoNotificationAt["9q8yy"] == recent)
|
||||||
#expect(context.synchronizedGeohashes.isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -605,7 +598,7 @@ struct GeoPresenceTrackerTests {
|
|||||||
#expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy"))
|
#expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy"))
|
||||||
|
|
||||||
// Cooldown elapsed: the geohash is re-stamped and the append is
|
// 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)
|
let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1)
|
||||||
context.lastGeoNotificationAt["9q8yy"] = stale
|
context.lastGeoNotificationAt["9q8yy"] = stale
|
||||||
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
|
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
|
||||||
@@ -614,7 +607,6 @@ struct GeoPresenceTrackerTests {
|
|||||||
let stamped = try #require(context.lastGeoNotificationAt["9q8yy"])
|
let stamped = try #require(context.lastGeoNotificationAt["9q8yy"])
|
||||||
#expect(stamped > stale)
|
#expect(stamped > stale)
|
||||||
#expect(context.appendedGeohashMessages.count == 1)
|
#expect(context.appendedGeohashMessages.count == 1)
|
||||||
#expect(context.synchronizedGeohashes.isEmpty)
|
|
||||||
}
|
}
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
|
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
|
||||||
@@ -661,10 +653,9 @@ struct GeoPresenceTrackerTests {
|
|||||||
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first)
|
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first)
|
||||||
await drainMainQueue()
|
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.map(\.message.id) == [first.id])
|
||||||
#expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4)))
|
#expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4)))
|
||||||
#expect(context.synchronizedGeohashes == ["u4pruyd"])
|
|
||||||
#expect(context.geohashActivityNotifications.count == 1)
|
#expect(context.geohashActivityNotifications.count == 1)
|
||||||
#expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd")
|
#expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd")
|
||||||
#expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash")
|
#expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash")
|
||||||
|
|||||||
@@ -49,21 +49,19 @@ private final class MockChatOutgoingContext: ChatOutgoingContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public timeline (local echo)
|
// Public timeline (local echo)
|
||||||
private(set) var appendedTimelineMessages: [(message: BitchatMessage, channel: ChannelID)] = []
|
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
|
||||||
private(set) var refreshedChannels: [ChannelID?] = []
|
|
||||||
private(set) var trimMessagesIfNeededCount = 0
|
|
||||||
private(set) var systemMessages: [String] = []
|
private(set) var systemMessages: [String] = []
|
||||||
|
|
||||||
func parseMentions(from content: String) -> [String] {
|
func parseMentions(from content: String) -> [String] {
|
||||||
content.contains("@bob") ? ["bob"] : []
|
content.contains("@bob") ? ["bob"] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
|
@discardableResult
|
||||||
appendedTimelineMessages.append((message, channel))
|
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) }
|
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||||
|
|
||||||
// Content dedup
|
// Content dedup
|
||||||
@@ -129,7 +127,7 @@ struct ChatOutgoingCoordinatorContextTests {
|
|||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
|
|
||||||
#expect(context.handledCommands == ["/who all"])
|
#expect(context.handledCommands == ["/who all"])
|
||||||
#expect(context.appendedTimelineMessages.isEmpty)
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
#expect(context.sentMeshMessages.isEmpty)
|
#expect(context.sentMeshMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +151,7 @@ struct ChatOutgoingCoordinatorContextTests {
|
|||||||
coordinator.sendMessage("dropped")
|
coordinator.sendMessage("dropped")
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.sentPrivateMessages.count == 1)
|
#expect(context.sentPrivateMessages.count == 1)
|
||||||
#expect(context.appendedTimelineMessages.isEmpty)
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -165,16 +163,14 @@ struct ChatOutgoingCoordinatorContextTests {
|
|||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
|
|
||||||
// Local echo uses the trimmed content, own nickname/peer ID, mentions.
|
// Local echo uses the trimmed content, own nickname/peer ID, mentions.
|
||||||
#expect(context.appendedTimelineMessages.count == 1)
|
#expect(context.appendedPublicMessages.count == 1)
|
||||||
let echo = context.appendedTimelineMessages[0]
|
let echo = context.appendedPublicMessages[0]
|
||||||
#expect(echo.message.content == "hello @bob")
|
#expect(echo.message.content == "hello @bob")
|
||||||
#expect(echo.message.sender == "me")
|
#expect(echo.message.sender == "me")
|
||||||
#expect(echo.message.senderPeerID == context.myPeerID)
|
#expect(echo.message.senderPeerID == context.myPeerID)
|
||||||
#expect(echo.message.mentions == ["bob"])
|
#expect(echo.message.mentions == ["bob"])
|
||||||
#expect(echo.channel == .mesh)
|
#expect(echo.conversationID == .mesh)
|
||||||
#expect(context.refreshedChannels == [.mesh])
|
|
||||||
#expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"])
|
#expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"])
|
||||||
#expect(context.trimMessagesIfNeededCount == 1)
|
|
||||||
|
|
||||||
// The mesh send carries the original (untrimmed) content and reuses
|
// The mesh send carries the original (untrimmed) content and reuses
|
||||||
// the echo's message ID and timestamp; activity is stamped for "mesh".
|
// 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
|
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
|
||||||
// the signed event's ID; the send context targets the same channel.
|
// the signed event's ID; the send context targets the same channel.
|
||||||
#expect(context.appendedTimelineMessages.count == 1)
|
#expect(context.appendedPublicMessages.count == 1)
|
||||||
let echo = context.appendedTimelineMessages[0].message
|
let echo = context.appendedPublicMessages[0].message
|
||||||
|
#expect(context.appendedPublicMessages[0].conversationID == .geohash("u4pruydq"))
|
||||||
#expect(echo.sender == "me#2222")
|
#expect(echo.sender == "me#2222")
|
||||||
#expect(context.recordedActivityKeys == ["geo:u4pruydq"])
|
#expect(context.recordedActivityKeys == ["geo:u4pruydq"])
|
||||||
#expect(context.sentGeohashContexts.count == 1)
|
#expect(context.sentGeohashContexts.count == 1)
|
||||||
@@ -215,7 +212,7 @@ struct ChatOutgoingCoordinatorContextTests {
|
|||||||
coordinator.sendMessage("doomed")
|
coordinator.sendMessage("doomed")
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
#expect(context.systemMessages.count == 1)
|
#expect(context.systemMessages.count == 1)
|
||||||
#expect(context.appendedTimelineMessages.count == 1)
|
#expect(context.appendedPublicMessages.count == 1)
|
||||||
#expect(context.sentGeohashContexts.count == 1)
|
#expect(context.sentGeohashContexts.count == 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,34 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
|
|||||||
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
||||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
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 chat session lifecycle
|
||||||
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
|
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
|
||||||
private(set) var syncedReadReceiptPeers: [PeerID] = []
|
private(set) var syncedReadReceiptPeers: [PeerID] = []
|
||||||
private(set) var begunChatSessions: [PeerID] = []
|
private(set) var begunChatSessions: [PeerID] = []
|
||||||
private(set) var privateStoreSyncCount = 0
|
|
||||||
private(set) var selectionStoreSyncCount = 0
|
private(set) var selectionStoreSyncCount = 0
|
||||||
private(set) var markedReadPeers: [PeerID] = []
|
private(set) var markedReadPeers: [PeerID] = []
|
||||||
|
|
||||||
@@ -60,7 +83,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
|
|||||||
begunChatSessions.append(peerID)
|
begunChatSessions.append(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 }
|
|
||||||
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
|
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
|
||||||
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
|
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
|
||||||
|
|
||||||
@@ -261,7 +283,6 @@ struct ChatPeerIdentityCoordinatorContextTests {
|
|||||||
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
|
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
|
||||||
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
|
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
|
||||||
#expect(context.begunChatSessions == [peerID])
|
#expect(context.begunChatSessions == [peerID])
|
||||||
#expect(context.privateStoreSyncCount == 1)
|
|
||||||
#expect(context.selectionStoreSyncCount == 1)
|
#expect(context.selectionStoreSyncCount == 1)
|
||||||
#expect(context.markedReadPeers == [peerID])
|
#expect(context.markedReadPeers == [peerID])
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,19 @@ private final class MockChatPeerListContext: ChatPeerListContext {
|
|||||||
// Connection & chat state
|
// Connection & chat state
|
||||||
var isConnected = false
|
var isConnected = false
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
privateChats[peerID] ?? []
|
||||||
|
}
|
||||||
var unreadPrivateMessages: Set<PeerID> = []
|
var unreadPrivateMessages: Set<PeerID> = []
|
||||||
var hasTrackedPrivateChatSelection = false
|
var hasTrackedPrivateChatSelection = false
|
||||||
private(set) var updatePrivateChatPeerIfNeededCount = 0
|
private(set) var updatePrivateChatPeerIfNeededCount = 0
|
||||||
private(set) var cleanupOldReadReceiptsCount = 0
|
private(set) var cleanupOldReadReceiptsCount = 0
|
||||||
|
|
||||||
|
func markPrivateChatRead(_ peerID: PeerID) {
|
||||||
|
unreadPrivateMessages.remove(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func updatePrivateChatPeerIfNeeded() {
|
func updatePrivateChatPeerIfNeeded() {
|
||||||
updatePrivateChatPeerIfNeededCount += 1
|
updatePrivateChatPeerIfNeededCount += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,90 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
notifyUIChangedCount += 1
|
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
|
// Peers & identity
|
||||||
var myPeerID = PeerID(str: "0011223344556677")
|
var myPeerID = PeerID(str: "0011223344556677")
|
||||||
var nicknamesByPeerID: [PeerID: String] = [:]
|
var nicknamesByPeerID: [PeerID: String] = [:]
|
||||||
@@ -149,10 +233,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
privateMessageNotifications.append((senderName, message, peerID))
|
privateMessageNotifications.append((senderName, message, peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// System messages & chat hygiene
|
// System messages
|
||||||
private(set) var systemMessages: [String] = []
|
private(set) var systemMessages: [String] = []
|
||||||
private(set) var meshOnlySystemMessages: [String] = []
|
private(set) var meshOnlySystemMessages: [String] = []
|
||||||
private(set) var sanitizedPeerIDs: [PeerID] = []
|
|
||||||
|
|
||||||
func addSystemMessage(_ content: String) {
|
func addSystemMessage(_ content: String) {
|
||||||
systemMessages.append(content)
|
systemMessages.append(content)
|
||||||
@@ -162,10 +245,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
meshOnlySystemMessages.append(content)
|
meshOnlySystemMessages.append(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeChat(for peerID: PeerID) {
|
|
||||||
sanitizedPeerIDs.append(peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let dummyIdentity = NostrIdentity(
|
static let dummyIdentity = NostrIdentity(
|
||||||
privateKey: Data(repeating: 0x11, count: 32),
|
privateKey: Data(repeating: 0x11, count: 32),
|
||||||
publicKey: Data(repeating: 0x22, count: 32),
|
publicKey: Data(repeating: 0x22, count: 32),
|
||||||
@@ -256,7 +335,9 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
// A different id appends.
|
// A different id appends.
|
||||||
coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID)
|
coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID)
|
||||||
#expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"])
|
#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("m1", targetPeerID: peerID))
|
||||||
#expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID))
|
#expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID))
|
||||||
@@ -436,7 +517,9 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.unreadPrivateMessages.isEmpty)
|
#expect(context.unreadPrivateMessages.isEmpty)
|
||||||
#expect(context.clearedFingerprints == [oldPeerID])
|
#expect(context.clearedFingerprints == [oldPeerID])
|
||||||
#expect(context.selectedPrivateChatPeer == newPeerID)
|
#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)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,13 @@ import BitFoundation
|
|||||||
/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`.
|
/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`.
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatPublicConversationContext: ChatPublicConversationContext {
|
private final class MockChatPublicConversationContext: ChatPublicConversationContext {
|
||||||
// Channel & visible timeline state
|
// Channel state
|
||||||
var messages: [BitchatMessage] = []
|
|
||||||
var activeChannel: ChannelID = .mesh
|
var activeChannel: ChannelID = .mesh
|
||||||
var currentGeohash: String?
|
var currentGeohash: String?
|
||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
var myPeerID = PeerID(str: "0011223344556677")
|
var myPeerID = PeerID(str: "0011223344556677")
|
||||||
private(set) var isBatchingPublic = false
|
private(set) var isBatchingPublic = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var trimMessagesCount = 0
|
|
||||||
|
|
||||||
func setPublicBatching(_ isBatching: Bool) {
|
func setPublicBatching(_ isBatching: Bool) {
|
||||||
isBatchingPublic = isBatching
|
isBatchingPublic = isBatching
|
||||||
@@ -43,102 +41,87 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
notifyUIChangedCount += 1
|
notifyUIChangedCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimMessagesIfNeeded() {
|
// Public conversation store (single-writer intents)
|
||||||
trimMessagesCount += 1
|
var conversations: [ConversationID: [BitchatMessage]] = [:]
|
||||||
}
|
|
||||||
|
|
||||||
// Public timeline store
|
|
||||||
var meshTimeline: [BitchatMessage] = []
|
|
||||||
var geoTimelines: [String: [BitchatMessage]] = [:]
|
|
||||||
private(set) var queuedGeohashSystemMessages: [String] = []
|
private(set) var queuedGeohashSystemMessages: [String] = []
|
||||||
|
|
||||||
func timelineMessages(for channel: ChannelID) -> [BitchatMessage] {
|
func publicMessages(in conversationID: ConversationID) -> [BitchatMessage] {
|
||||||
switch channel {
|
conversations[conversationID] ?? []
|
||||||
case .mesh: return meshTimeline
|
|
||||||
case .location(let channel): return geoTimelines[channel.geohash] ?? []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
|
@discardableResult
|
||||||
switch channel {
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
|
||||||
case .mesh: meshTimeline.append(message)
|
guard conversations[conversationID]?.contains(where: { $0.id == message.id }) != true else {
|
||||||
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 {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
geoTimelines[geohash, default: []].append(message)
|
conversations[conversationID, default: []].append(message)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeTimelineMessage(withID id: String) -> BitchatMessage? {
|
@discardableResult
|
||||||
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||||
return meshTimeline.remove(at: index)
|
appendPublicMessage(message, to: .geohash(geohash.lowercased()))
|
||||||
}
|
}
|
||||||
for (geohash, timeline) in geoTimelines {
|
|
||||||
guard let index = timeline.firstIndex(where: { $0.id == id }) else { continue }
|
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
|
var updated = timeline
|
||||||
let removed = updated.remove(at: index)
|
let removed = updated.remove(at: index)
|
||||||
geoTimelines[geohash] = updated
|
conversations[conversationID] = updated
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
||||||
geoTimelines[geohash]?.removeAll(where: predicate)
|
conversations[.geohash(geohash.lowercased())]?.removeAll(where: predicate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTimeline(for channel: ChannelID) {
|
private(set) var clearedConversations: [ConversationID] = []
|
||||||
switch channel {
|
|
||||||
case .mesh: meshTimeline.removeAll()
|
|
||||||
case .location(let channel): geoTimelines[channel.geohash] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func timelineGeohashKeys() -> [String] {
|
func clearPublicConversation(_ conversationID: ConversationID) {
|
||||||
Array(geoTimelines.keys)
|
clearedConversations.append(conversationID)
|
||||||
|
conversations[conversationID] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
func queueGeohashSystemMessage(_ content: String) {
|
func queueGeohashSystemMessage(_ content: String) {
|
||||||
queuedGeohashSystemMessages.append(content)
|
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
|
// Private chats
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
var unreadPrivateMessages: Set<PeerID> = []
|
var unreadPrivateMessages: Set<PeerID> = []
|
||||||
|
private(set) var removedPrivateChats: [PeerID] = []
|
||||||
private(set) var cleanedUpFileMessageIDs: [String] = []
|
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) {
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||||
cleanedUpFileMessageIDs.append(message.id)
|
cleanedUpFileMessageIDs.append(message.id)
|
||||||
}
|
}
|
||||||
@@ -204,7 +187,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
var blockedMessageIDs: Set<String> = []
|
var blockedMessageIDs: Set<String> = []
|
||||||
var rateLimitAllowed = true
|
var rateLimitAllowed = true
|
||||||
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
|
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] = [:]
|
var stablePeerIDs: [PeerID: PeerID] = [:]
|
||||||
|
|
||||||
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
||||||
@@ -220,8 +204,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
|
|||||||
return rateLimitAllowed
|
return rateLimitAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueuePublicMessage(_ message: BitchatMessage) {
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
||||||
enqueuedMessageIDs.append(message.id)
|
enqueuedMessages.append((message.id, conversationID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? {
|
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? {
|
||||||
@@ -291,24 +275,24 @@ private func makePublicMessage(
|
|||||||
struct ChatPublicConversationCoordinatorContextTests {
|
struct ChatPublicConversationCoordinatorContextTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handlePublicMessage_meshMessage_appendsSyncsStoreAndEnqueues() async {
|
func handlePublicMessage_meshMessage_enqueuesForBatchedStoreCommit() async {
|
||||||
let context = MockChatPublicConversationContext()
|
let context = MockChatPublicConversationContext()
|
||||||
let coordinator = ChatPublicConversationCoordinator(context: context)
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
||||||
let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh")
|
let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh")
|
||||||
|
|
||||||
coordinator.handlePublicMessage(message)
|
coordinator.handlePublicMessage(message)
|
||||||
|
|
||||||
#expect(context.meshTimeline.map(\.id) == ["mesh-msg-1"])
|
// Visible-channel arrival: buffered for the batched pipeline flush
|
||||||
#expect(context.replacedChannelMessages.count == 1)
|
// (which commits to the store), not appended directly.
|
||||||
#expect(context.replacedChannelMessages.first?.channel == .mesh)
|
|
||||||
#expect(context.replacedChannelMessages.first?.messageIDs == ["mesh-msg-1"])
|
|
||||||
#expect(context.rateLimitChecks.count == 1)
|
#expect(context.rateLimitChecks.count == 1)
|
||||||
#expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011")
|
#expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011")
|
||||||
#expect(context.rateLimitChecks.first?.contentKey == "hello mesh")
|
#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.
|
// Already committed to the store: not re-enqueued.
|
||||||
context.messages = [message]
|
context.appendPublicMessage(message, to: .mesh)
|
||||||
coordinator.handlePublicMessage(message)
|
coordinator.handlePublicMessage(message)
|
||||||
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
|
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
|
||||||
}
|
}
|
||||||
@@ -322,14 +306,14 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
context.blockedMessageIDs = ["blocked-msg"]
|
context.blockedMessageIDs = ["blocked-msg"]
|
||||||
coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg"))
|
coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg"))
|
||||||
#expect(context.rateLimitChecks.isEmpty)
|
#expect(context.rateLimitChecks.isEmpty)
|
||||||
#expect(context.meshTimeline.isEmpty)
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
||||||
#expect(context.enqueuedMessageIDs.isEmpty)
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
||||||
|
|
||||||
// Rate limited: consulted, then dropped before storage.
|
// Rate limited: consulted, then dropped before storage.
|
||||||
context.rateLimitAllowed = false
|
context.rateLimitAllowed = false
|
||||||
coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg"))
|
coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg"))
|
||||||
#expect(context.rateLimitChecks.count == 1)
|
#expect(context.rateLimitChecks.count == 1)
|
||||||
#expect(context.meshTimeline.isEmpty)
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
||||||
#expect(context.enqueuedMessageIDs.isEmpty)
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,16 +330,15 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
senderPeerID: PeerID(nostr: senderHex)
|
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
|
context.activeChannel = .mesh
|
||||||
coordinator.handlePublicMessage(geoMessage)
|
coordinator.handlePublicMessage(geoMessage)
|
||||||
#expect(context.geoTimelines[geohash]?.map(\.id) == ["geo-msg-1"])
|
#expect(context.publicMessages(in: .geohash(geohash)).map(\.id) == ["geo-msg-1"])
|
||||||
#expect(context.replacedConversationMessages.count == 1)
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
||||||
#expect(context.replacedConversationMessages.first?.conversation == .geohash(geohash))
|
|
||||||
#expect(context.meshTimeline.isEmpty)
|
|
||||||
#expect(context.enqueuedMessageIDs.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))
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
||||||
let second = makePublicMessage(
|
let second = makePublicMessage(
|
||||||
id: "geo-msg-2",
|
id: "geo-msg-2",
|
||||||
@@ -363,7 +346,8 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
senderPeerID: PeerID(nostr: senderHex)
|
senderPeerID: PeerID(nostr: senderHex)
|
||||||
)
|
)
|
||||||
coordinator.handlePublicMessage(second)
|
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
|
@Test @MainActor
|
||||||
@@ -378,8 +362,7 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
|
|
||||||
context.currentGeohash = geohash
|
context.currentGeohash = geohash
|
||||||
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
||||||
context.geoTimelines[geohash] = [geoMessage]
|
context.conversations[.geohash(geohash)] = [geoMessage]
|
||||||
context.messages = [geoMessage]
|
|
||||||
context.nostrKeyMapping = [senderPeerID: hex, convKey: hex]
|
context.nostrKeyMapping = [senderPeerID: hex, convKey: hex]
|
||||||
context.privateChats[convKey] = [geoMessage]
|
context.privateChats[convKey] = [geoMessage]
|
||||||
context.unreadPrivateMessages = [convKey]
|
context.unreadPrivateMessages = [convKey]
|
||||||
@@ -388,13 +371,14 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
|
|
||||||
#expect(context.blockedNostrPubkeys.contains(hex))
|
#expect(context.blockedNostrPubkeys.contains(hex))
|
||||||
#expect(context.removedGeoParticipants == [hex])
|
#expect(context.removedGeoParticipants == [hex])
|
||||||
#expect(context.geoTimelines[geohash]?.isEmpty == true)
|
|
||||||
#expect(context.privateChats[convKey] == nil)
|
#expect(context.privateChats[convKey] == nil)
|
||||||
#expect(context.unreadPrivateMessages.isEmpty)
|
#expect(context.unreadPrivateMessages.isEmpty)
|
||||||
#expect(context.nostrKeyMapping.isEmpty)
|
#expect(context.nostrKeyMapping.isEmpty)
|
||||||
// The blocked user's visible message is gone; a system notice was added.
|
// The blocked user's message is purged from the geohash conversation
|
||||||
#expect(!context.messages.contains(where: { $0.id == "geo-bad-1" }))
|
// (the visible timeline is the same conversation now); a system
|
||||||
#expect(context.messages.last?.sender == "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")
|
coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
|
||||||
#expect(!context.blockedNostrPubkeys.contains(hex))
|
#expect(!context.blockedNostrPubkeys.contains(hex))
|
||||||
@@ -406,36 +390,27 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
let coordinator = ChatPublicConversationCoordinator(context: context)
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let message = makePublicMessage(id: "doomed-msg")
|
let message = makePublicMessage(id: "doomed-msg")
|
||||||
context.messages = [message]
|
context.conversations[.mesh] = [message]
|
||||||
context.meshTimeline = [message]
|
|
||||||
context.privateChats[peerID] = [message]
|
context.privateChats[peerID] = [message]
|
||||||
|
|
||||||
coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true)
|
coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true)
|
||||||
|
|
||||||
#expect(context.messages.isEmpty)
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
||||||
#expect(context.meshTimeline.isEmpty)
|
|
||||||
#expect(context.privateChats[peerID] == nil)
|
#expect(context.privateChats[peerID] == nil)
|
||||||
#expect(context.cleanedUpFileMessageIDs == ["doomed-msg"])
|
#expect(context.cleanedUpFileMessageIDs == ["doomed-msg"])
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#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
|
@Test @MainActor
|
||||||
func addPublicSystemMessage_appendsRefreshesAndRecordsContentKey() async {
|
func addPublicSystemMessage_appendsToActiveConversationAndRecordsContentKey() async {
|
||||||
let context = MockChatPublicConversationContext()
|
let context = MockChatPublicConversationContext()
|
||||||
let coordinator = ChatPublicConversationCoordinator(context: context)
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
||||||
|
|
||||||
coordinator.addPublicSystemMessage("Tor Ready")
|
coordinator.addPublicSystemMessage("Tor Ready")
|
||||||
|
|
||||||
#expect(context.meshTimeline.count == 1)
|
#expect(context.publicMessages(in: .mesh).count == 1)
|
||||||
#expect(context.meshTimeline.first?.sender == "system")
|
#expect(context.publicMessages(in: .mesh).first?.sender == "system")
|
||||||
// refreshVisibleMessages mirrors the timeline into the visible list.
|
|
||||||
#expect(context.messages.map(\.id) == context.meshTimeline.map(\.id))
|
|
||||||
#expect(context.recordedContentKeys.map(\.key) == ["tor ready"])
|
#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.
|
// On mesh, geohash-only system messages are queued for the next geo visit.
|
||||||
coordinator.addGeohashOnlySystemMessage("geo notice")
|
coordinator.addGeohashOnlySystemMessage("geo notice")
|
||||||
@@ -459,22 +434,20 @@ struct ChatPublicConversationCoordinatorContextTests {
|
|||||||
let coordinator = ChatPublicConversationCoordinator(context: context)
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
||||||
let pipeline = PublicMessagePipeline()
|
let pipeline = PublicMessagePipeline()
|
||||||
let message = makePublicMessage(id: "pipeline-msg")
|
let message = makePublicMessage(id: "pipeline-msg")
|
||||||
context.messages = [message]
|
|
||||||
context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42)
|
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, normalizeContent: "HeLLo") == "hello")
|
||||||
#expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42))
|
#expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42))
|
||||||
|
|
||||||
coordinator.pipeline(pipeline, setMessages: [])
|
// Commit lands in the store via the append intent; a duplicate ID
|
||||||
#expect(context.messages.isEmpty)
|
// 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))
|
coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7))
|
||||||
#expect(context.recordedContentKeys.map(\.key) == ["key-2"])
|
#expect(context.recordedContentKeys.map(\.key) == ["key-2"])
|
||||||
|
|
||||||
coordinator.pipelineTrimMessages(pipeline)
|
|
||||||
#expect(context.trimMessagesCount == 1)
|
|
||||||
|
|
||||||
coordinator.pipelinePrewarmMessage(pipeline, message: message)
|
coordinator.pipelinePrewarmMessage(pipeline, message: message)
|
||||||
#expect(context.prewarmedMessageIDs == ["pipeline-msg"])
|
#expect(context.prewarmedMessageIDs == ["pipeline-msg"])
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,39 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
var nickname = "me"
|
var nickname = "me"
|
||||||
var myPeerID = PeerID(str: "0011223344556677")
|
var myPeerID = PeerID(str: "0011223344556677")
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
privateChats[peerID] ?? []
|
||||||
|
}
|
||||||
var unreadPrivateMessages: Set<PeerID> = []
|
var unreadPrivateMessages: Set<PeerID> = []
|
||||||
var selectedPrivateChatPeer: PeerID?
|
var selectedPrivateChatPeer: PeerID?
|
||||||
private(set) var unmarkedReadReceiptBatches: [[String]] = []
|
private(set) var unmarkedReadReceiptBatches: [[String]] = []
|
||||||
private(set) var notifyUIChangedCount = 0
|
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]) {
|
func unmarkReadReceiptsSent(_ ids: [String]) {
|
||||||
unmarkedReadReceiptBatches.append(ids)
|
unmarkedReadReceiptBatches.append(ids)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .read(by: "Peer", at: Date())
|
deliveryStatus: .read(by: "Peer", at: Date())
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
// Action: try to downgrade to .delivered
|
// Action: try to downgrade to .delivered
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||||
@@ -85,7 +85,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
// Action: upgrade to .delivered
|
// Action: upgrade to .delivered
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
|
||||||
@@ -114,7 +114,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent)
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
// Action: upgrade to .read
|
// Action: upgrade to .read
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
|
||||||
@@ -173,7 +173,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
// Action: receive read receipt
|
// Action: receive read receipt
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
@@ -207,7 +207,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
||||||
viewModel.isStartupPhase = false
|
viewModel.isStartupPhase = false
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
viewModel.messages.append(message)
|
viewModel.seedPublicMessages([message])
|
||||||
|
|
||||||
// Action: update to .sent
|
// Action: update to .sent
|
||||||
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
@@ -253,7 +253,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
let firstPeerID = PeerID(str: "0102030405060708")
|
let firstPeerID = PeerID(str: "0102030405060708")
|
||||||
let secondPeerID = PeerID(str: "1112131415161718")
|
let secondPeerID = PeerID(str: "1112131415161718")
|
||||||
|
|
||||||
viewModel.messages = [
|
viewModel.seedPublicMessages([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
@@ -264,8 +264,8 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
]
|
])
|
||||||
viewModel.privateChats[firstPeerID] = [
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
@@ -277,8 +277,8 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
]
|
], for: firstPeerID)
|
||||||
viewModel.privateChats[secondPeerID] = [
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
@@ -290,7 +290,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
]
|
], for: secondPeerID)
|
||||||
|
|
||||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
@@ -304,7 +304,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
|
func deliveryStatus_survivesPrivateChatReorder() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let messageID = "reordered-msg-1"
|
let messageID = "reordered-msg-1"
|
||||||
@@ -331,10 +331,15 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.privateChats[peerID] = [targetMessage, olderMessage]
|
viewModel.seedPrivateChat([targetMessage], for: peerID)
|
||||||
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
#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(
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
status: .read(by: "Peer", at: Date())
|
status: .read(by: "Peer", at: Date())
|
||||||
@@ -378,16 +383,31 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
// MARK: - Mock Delivery Context
|
// MARK: - Mock Delivery Context
|
||||||
|
|
||||||
/// Lightweight stand-in for `ChatDeliveryContext` proving that
|
/// 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
|
@MainActor
|
||||||
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||||
var messages: [BitchatMessage] = []
|
let store = ConversationStore()
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
||||||
var sentReadReceipts: Set<String> = []
|
var sentReadReceipts: Set<String> = []
|
||||||
var isStartupPhase = false
|
var isStartupPhase = false
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
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 {
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
let oldCount = sentReadReceipts.count
|
let oldCount = sentReadReceipts.count
|
||||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
@@ -404,12 +424,16 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage {
|
private func makePrivateMessage(
|
||||||
|
id: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) -> BitchatMessage {
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: id,
|
id: id,
|
||||||
sender: "me",
|
sender: "me",
|
||||||
content: "Test message",
|
content: "Test message",
|
||||||
timestamp: Date(),
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Peer",
|
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
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` —
|
/// 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 {
|
struct ChatDeliveryCoordinatorContextTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -430,7 +472,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let messageID = "mock-msg-1"
|
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(
|
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
||||||
messageID,
|
messageID,
|
||||||
@@ -438,7 +480,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
#expect(didUpdate)
|
#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.notifyUIChangedCount == 1)
|
||||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||||
}
|
}
|
||||||
@@ -449,13 +491,16 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
let messageID = "mock-msg-2"
|
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(
|
coordinator.didReceiveReadReceipt(
|
||||||
ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer")
|
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.notifyUIChangedCount == 1)
|
||||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||||
}
|
}
|
||||||
@@ -464,22 +509,12 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
context.messages = [
|
context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh)
|
||||||
BitchatMessage(
|
|
||||||
id: "public-mock-1",
|
|
||||||
sender: "me",
|
|
||||||
content: "Public message",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
isPrivate: false,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
||||||
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
||||||
#expect(didUpdate)
|
#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.markedDeliveredMessageIDs.isEmpty)
|
||||||
#expect(context.notifyUIChangedCount == 1)
|
#expect(context.notifyUIChangedCount == 1)
|
||||||
|
|
||||||
@@ -489,57 +524,98 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
#expect(context.notifyUIChangedCount == 1)
|
#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
|
@Test @MainActor
|
||||||
func middleInsertedMessage_isFoundAfterIndexWasBuilt() async {
|
func middleInsertedMessage_isStillUpdatableByID() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let makePublic = { (id: String) in
|
context.store.append(
|
||||||
BitchatMessage(
|
makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
|
||||||
id: id,
|
to: .mesh
|
||||||
sender: "me",
|
)
|
||||||
content: "Public message",
|
context.store.append(
|
||||||
timestamp: Date(),
|
makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
|
||||||
isRelay: false,
|
to: .mesh
|
||||||
isPrivate: false,
|
)
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
}
|
|
||||||
context.messages = [makePublic("public-a"), makePublic("public-b")]
|
|
||||||
|
|
||||||
// Build the incremental index.
|
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
||||||
|
|
||||||
// Out-of-order arrival: PublicMessagePipeline inserts by timestamp,
|
// Out-of-order arrival: the store inserts by timestamp, shifting the
|
||||||
// so the count grows while the tail ID stays the same.
|
// tail's position.
|
||||||
context.messages.insert(makePublic("public-mid"), at: 1)
|
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
|
// Both the inserted message and the shifted tail stay updatable.
|
||||||
// not be updated through a stale index entry.
|
|
||||||
#expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent))
|
#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(coordinator.updateMessageDeliveryStatus("public-b", status: .sent))
|
||||||
#expect(isSent(context.messages[2].deliveryStatus))
|
#expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async {
|
func middleInsertedPrivateMessage_isStillUpdatableByID() async {
|
||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
let peerID = PeerID(str: "0102030405060708")
|
||||||
context.privateChats[peerID] = [
|
let conversationID = ConversationID.directPeer(peerID)
|
||||||
makePrivateMessage(id: "pm-a", status: .sending),
|
context.store.append(
|
||||||
makePrivateMessage(id: "pm-b", status: .sending)
|
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))
|
#expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent))
|
||||||
|
|
||||||
// Timestamp re-sort (sanitizeChat) can place a late arrival mid-array.
|
// A late arrival with an older timestamp lands mid-array.
|
||||||
context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1)
|
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(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(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
|
@Test @MainActor
|
||||||
@@ -547,7 +623,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
|||||||
let context = MockChatDeliveryContext()
|
let context = MockChatDeliveryContext()
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
let peerID = PeerID(str: "0102030405060708")
|
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"]
|
context.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
||||||
|
|
||||||
// Startup phase: cleanup must be a no-op.
|
// Startup phase: cleanup must be a no-op.
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ struct ChatViewModelPrivateChatExtensionTests {
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
senderPeerID: oldPeerID
|
senderPeerID: oldPeerID
|
||||||
)
|
)
|
||||||
viewModel.privateChats[oldPeerID] = [oldMessage]
|
viewModel.seedPrivateChat([oldMessage], for: oldPeerID)
|
||||||
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
|
viewModel.peerIDToPublicKeyFingerprint[oldPeerID] = fingerprint
|
||||||
|
|
||||||
// Setup new peer fingerprint
|
// Setup new peer fingerprint
|
||||||
@@ -444,7 +444,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
let messageID = "geo-ack-delivered"
|
let messageID = "geo-ack-delivered"
|
||||||
|
|
||||||
viewModel.privateChats[convKey] = [
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
@@ -456,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
senderPeerID: viewModel.meshService.myPeerID,
|
senderPeerID: viewModel.meshService.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
]
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
@@ -482,7 +482,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
let messageID = "geo-ack-read"
|
let messageID = "geo-ack-read"
|
||||||
|
|
||||||
viewModel.privateChats[convKey] = [
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
@@ -494,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
senderPeerID: viewModel.meshService.myPeerID,
|
senderPeerID: viewModel.meshService.myPeerID,
|
||||||
deliveryStatus: .delivered(to: "Friend", at: Date())
|
deliveryStatus: .delivered(to: "Friend", at: Date())
|
||||||
)
|
)
|
||||||
]
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
@@ -578,7 +578,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
let messageID = "gift-delivered"
|
let messageID = "gift-delivered"
|
||||||
|
|
||||||
viewModel.privateChats[convKey] = [
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
@@ -590,7 +590,7 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
senderPeerID: viewModel.meshService.myPeerID,
|
senderPeerID: viewModel.meshService.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
]
|
], for: convKey)
|
||||||
|
|
||||||
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
@@ -1095,7 +1095,7 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
senderPeerID: viewModel.meshService.myPeerID,
|
senderPeerID: viewModel.meshService.myPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
|
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
|
||||||
|
|
||||||
viewModel.cancelMediaSend(messageID: message.id)
|
viewModel.cancelMediaSend(messageID: message.id)
|
||||||
@@ -1124,7 +1124,7 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
senderPeerID: viewModel.meshService.myPeerID,
|
senderPeerID: viewModel.meshService.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
|
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
|
||||||
|
|
||||||
viewModel.deleteMediaMessage(messageID: message.id)
|
viewModel.deleteMediaMessage(messageID: message.id)
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ struct ChatViewModelRefactoringTests {
|
|||||||
// Wait for async processing with proper timeout
|
// Wait for async processing with proper timeout
|
||||||
let found = await TestHelpers.waitUntil(
|
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
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ struct ChatViewModelIdentityTests {
|
|||||||
senderPeerID: oldPeerID,
|
senderPeerID: oldPeerID,
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
viewModel.privateChats[oldPeerID] = [existingMessage]
|
viewModel.seedPrivateChat([existingMessage], for: oldPeerID)
|
||||||
viewModel.startPrivateChat(with: oldPeerID)
|
viewModel.startPrivateChat(with: oldPeerID)
|
||||||
|
|
||||||
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
|
#expect(viewModel.selectedPrivateChatPeer == oldPeerID)
|
||||||
@@ -345,8 +345,8 @@ struct ChatViewModelServiceLifecycleTests {
|
|||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
viewModel.unreadPrivateMessages.insert(peerID)
|
viewModel.markPrivateChatUnread(peerID)
|
||||||
viewModel.selectedPrivateChatPeer = peerID
|
viewModel.selectedPrivateChatPeer = peerID
|
||||||
|
|
||||||
viewModel.handleDidBecomeActive()
|
viewModel.handleDidBecomeActive()
|
||||||
@@ -438,7 +438,7 @@ struct ChatViewModelReceivingTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let found = await TestHelpers.waitUntil({
|
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)
|
}, timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
#expect(found)
|
#expect(found)
|
||||||
@@ -497,7 +497,7 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
mentions: nil,
|
mentions: nil,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
viewModel.didReceiveNoisePayload(
|
viewModel.didReceiveNoisePayload(
|
||||||
from: peerID,
|
from: peerID,
|
||||||
@@ -535,7 +535,7 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
mentions: nil,
|
mentions: nil,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
viewModel.privateChats[peerID] = [message]
|
viewModel.seedPrivateChat([message], for: peerID)
|
||||||
|
|
||||||
viewModel.didReceiveNoisePayload(
|
viewModel.didReceiveNoisePayload(
|
||||||
from: peerID,
|
from: peerID,
|
||||||
@@ -553,10 +553,7 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
||||||
let messages = viewModel.conversationStore.directMessages(
|
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
for: peerID,
|
|
||||||
identityResolver: viewModel.identityResolver
|
|
||||||
)
|
|
||||||
guard let status = messages.first?.deliveryStatus else { return false }
|
guard let status = messages.first?.deliveryStatus else { return false }
|
||||||
if case .read = status {
|
if case .read = status {
|
||||||
return true
|
return true
|
||||||
@@ -709,10 +706,12 @@ struct ChatViewModelPublicConversationTests {
|
|||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
viewModel.addPublicSystemMessage("system refresh test")
|
viewModel.addPublicSystemMessage("system refresh test")
|
||||||
viewModel.messages.removeAll()
|
|
||||||
viewModel.refreshVisibleMessages(from: .mesh)
|
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.messages.last?.content == "system refresh test")
|
||||||
|
#expect(viewModel.publicMessages(for: .mesh).last?.content == "system refresh test")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -726,6 +725,18 @@ struct ChatViewModelPublicConversationTests {
|
|||||||
viewModel.refreshVisibleMessages(from: .mesh)
|
viewModel.refreshVisibleMessages(from: .mesh)
|
||||||
|
|
||||||
#expect(viewModel.messages.isEmpty)
|
#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 {
|
func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let stalePeer = PeerID(str: "00000000000000a2")
|
let stalePeer = PeerID(str: "00000000000000a2")
|
||||||
viewModel.unreadPrivateMessages = [stalePeer]
|
viewModel.markPrivateChatUnread(stalePeer)
|
||||||
|
|
||||||
viewModel.didUpdatePeerList([])
|
viewModel.didUpdatePeerList([])
|
||||||
|
|
||||||
@@ -798,8 +809,8 @@ struct ChatViewModelPeerTests {
|
|||||||
senderPeerID: stablePeer,
|
senderPeerID: stablePeer,
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
viewModel.privateChats[stablePeer] = [message]
|
viewModel.seedPrivateChat([message], for: stablePeer)
|
||||||
viewModel.unreadPrivateMessages = [stablePeer]
|
viewModel.markPrivateChatUnread(stablePeer)
|
||||||
|
|
||||||
viewModel.didUpdatePeerList([])
|
viewModel.didUpdatePeerList([])
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
@@ -876,33 +887,32 @@ struct ChatViewModelPrivateChatSelectionTests {
|
|||||||
let older = Date().addingTimeInterval(-120)
|
let older = Date().addingTimeInterval(-120)
|
||||||
let newer = Date().addingTimeInterval(-30)
|
let newer = Date().addingTimeInterval(-30)
|
||||||
|
|
||||||
viewModel.privateChats = [
|
viewModel.seedPrivateChat([
|
||||||
peerA: [
|
BitchatMessage(
|
||||||
BitchatMessage(
|
id: "a-1",
|
||||||
id: "a-1",
|
sender: "A",
|
||||||
sender: "A",
|
content: "Old",
|
||||||
content: "Old",
|
timestamp: older,
|
||||||
timestamp: older,
|
isRelay: false,
|
||||||
isRelay: false,
|
isPrivate: true,
|
||||||
isPrivate: true,
|
recipientNickname: "Me",
|
||||||
recipientNickname: "Me",
|
senderPeerID: peerA
|
||||||
senderPeerID: peerA
|
)
|
||||||
)
|
], for: peerA)
|
||||||
],
|
viewModel.seedPrivateChat([
|
||||||
peerB: [
|
BitchatMessage(
|
||||||
BitchatMessage(
|
id: "b-1",
|
||||||
id: "b-1",
|
sender: "B",
|
||||||
sender: "B",
|
content: "New",
|
||||||
content: "New",
|
timestamp: newer,
|
||||||
timestamp: newer,
|
isRelay: false,
|
||||||
isRelay: false,
|
isPrivate: true,
|
||||||
isPrivate: true,
|
recipientNickname: "Me",
|
||||||
recipientNickname: "Me",
|
senderPeerID: peerB
|
||||||
senderPeerID: peerB
|
)
|
||||||
)
|
], for: peerB)
|
||||||
]
|
viewModel.markPrivateChatUnread(peerA)
|
||||||
]
|
viewModel.markPrivateChatUnread(peerB)
|
||||||
viewModel.unreadPrivateMessages = [peerA, peerB]
|
|
||||||
|
|
||||||
viewModel.openMostRelevantPrivateChat()
|
viewModel.openMostRelevantPrivateChat()
|
||||||
|
|
||||||
@@ -918,32 +928,30 @@ struct ChatViewModelPrivateChatSelectionTests {
|
|||||||
let older = Date().addingTimeInterval(-200)
|
let older = Date().addingTimeInterval(-200)
|
||||||
let newer = Date().addingTimeInterval(-20)
|
let newer = Date().addingTimeInterval(-20)
|
||||||
|
|
||||||
viewModel.privateChats = [
|
viewModel.seedPrivateChat([
|
||||||
peerA: [
|
BitchatMessage(
|
||||||
BitchatMessage(
|
id: "a-1",
|
||||||
id: "a-1",
|
sender: "A",
|
||||||
sender: "A",
|
content: "Old",
|
||||||
content: "Old",
|
timestamp: older,
|
||||||
timestamp: older,
|
isRelay: false,
|
||||||
isRelay: false,
|
isPrivate: true,
|
||||||
isPrivate: true,
|
recipientNickname: "Me",
|
||||||
recipientNickname: "Me",
|
senderPeerID: peerA
|
||||||
senderPeerID: peerA
|
)
|
||||||
)
|
], for: peerA)
|
||||||
],
|
viewModel.seedPrivateChat([
|
||||||
peerB: [
|
BitchatMessage(
|
||||||
BitchatMessage(
|
id: "b-1",
|
||||||
id: "b-1",
|
sender: "B",
|
||||||
sender: "B",
|
content: "New",
|
||||||
content: "New",
|
timestamp: newer,
|
||||||
timestamp: newer,
|
isRelay: false,
|
||||||
isRelay: false,
|
isPrivate: true,
|
||||||
isPrivate: true,
|
recipientNickname: "Me",
|
||||||
recipientNickname: "Me",
|
senderPeerID: peerB
|
||||||
senderPeerID: peerB
|
)
|
||||||
)
|
], for: peerB)
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
viewModel.openMostRelevantPrivateChat()
|
viewModel.openMostRelevantPrivateChat()
|
||||||
|
|
||||||
@@ -1002,7 +1010,7 @@ struct ChatViewModelPanicTests {
|
|||||||
|
|
||||||
// Set up some state
|
// Set up some state
|
||||||
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
transport.connectedPeers.insert(PeerID(str: "PEER1"))
|
||||||
viewModel.messages = [
|
viewModel.seedPublicMessages([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "panic-1",
|
id: "panic-1",
|
||||||
sender: "Tester",
|
sender: "Tester",
|
||||||
@@ -1010,8 +1018,8 @@ struct ChatViewModelPanicTests {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
]
|
])
|
||||||
viewModel.privateChats[PeerID(str: "PEER1")] = [
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "pm-1",
|
id: "pm-1",
|
||||||
sender: "Peer",
|
sender: "Peer",
|
||||||
@@ -1022,8 +1030,8 @@ struct ChatViewModelPanicTests {
|
|||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: PeerID(str: "PEER1")
|
senderPeerID: PeerID(str: "PEER1")
|
||||||
)
|
)
|
||||||
]
|
], for: PeerID(str: "PEER1"))
|
||||||
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
|
viewModel.markPrivateChatUnread(PeerID(str: "PEER1"))
|
||||||
|
|
||||||
viewModel.panicClearAllData()
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,12 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
|||||||
clearCurrentPublicTimelineCallCount += 1
|
clearCurrentPublicTimelineCallCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private(set) var clearedPrivateChats: [PeerID] = []
|
||||||
|
func clearPrivateChat(_ peerID: PeerID) {
|
||||||
|
clearedPrivateChats.append(peerID)
|
||||||
|
privateChats[peerID] = []
|
||||||
|
}
|
||||||
|
|
||||||
func sendPublicRaw(_ content: String) {
|
func sendPublicRaw(_ content: String) {
|
||||||
sentPublicRawMessages.append(content)
|
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")
|
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
|
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the
|
||||||
/// location index: 2000 public + 50x40 private messages, 500 status
|
/// `ConversationStore`'s message-ID → conversation map: 2000 public
|
||||||
/// updates per pass. Statuses alternate sent <-> delivered so every call
|
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
|
||||||
/// performs a real update (never the skip path).
|
/// private messages, 500 status updates per pass. Statuses alternate
|
||||||
|
/// sent <-> delivered so every call performs a real update (never the
|
||||||
|
/// skip path).
|
||||||
func testDeliveryStatusIncrementalUpdates() {
|
func testDeliveryStatusIncrementalUpdates() {
|
||||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||||
// Warm the index so the measure block exercises the incremental path.
|
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||||
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) }
|
|
||||||
}
|
|
||||||
XCTAssertEqual(targetIDs.count, 500)
|
XCTAssertEqual(targetIDs.count, 500)
|
||||||
|
|
||||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
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")
|
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
|
/// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same
|
||||||
/// the head of the timeline) invalidates the index, so the next status
|
/// scale as 4a, without the coordinator/context wrapping — the store-side
|
||||||
/// update triggers a full rebuild over ~4000 message locations.
|
/// cost of an ID-only delivery update (map lookup + per-conversation
|
||||||
func testDeliveryStatusIndexRebuild() {
|
/// 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 context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
let store = context.store
|
||||||
// Warm the index before the first insertion invalidates it.
|
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
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)
|
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
var pass = 0
|
|
||||||
var toggle = false
|
var toggle = false
|
||||||
var samples: [TimeInterval] = []
|
var samples: [TimeInterval] = []
|
||||||
|
|
||||||
measure {
|
measure {
|
||||||
precondition(pass < insertions.count, "add more pre-built insertions")
|
|
||||||
context.messages.insert(insertions[pass], at: 0)
|
|
||||||
pass += 1
|
|
||||||
toggle.toggle()
|
toggle.toggle()
|
||||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||||
let start = Date()
|
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))
|
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
|
// MARK: - 5. Message formatting
|
||||||
@@ -328,6 +312,176 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("formatting.formatMessage", samples: samples, operations: batchSize, unit: "messages")
|
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
|
// MARK: - Fixtures
|
||||||
|
|
||||||
/// Builds deterministic signed kind-20000 geohash events. Content cycles
|
/// Builds deterministic signed kind-20000 geohash events. Content cycles
|
||||||
@@ -396,13 +550,11 @@ private final class PerfNostrContext: ChatNostrContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var messages: [BitchatMessage] = []
|
var messages: [BitchatMessage] = []
|
||||||
func resetPublicMessagePipeline() {}
|
func flushPublicMessagePipeline() {}
|
||||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {}
|
|
||||||
func refreshVisibleMessages(from channel: ChannelID?) {}
|
func refreshVisibleMessages(from channel: ChannelID?) {}
|
||||||
func addPublicSystemMessage(_ content: String) {}
|
func addPublicSystemMessage(_ content: String) {}
|
||||||
func drainPendingGeohashSystemMessages() -> [String] { [] }
|
func drainPendingGeohashSystemMessages() -> [String] { [] }
|
||||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true }
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true }
|
||||||
func synchronizePublicConversationStore(forGeohash geohash: String) {}
|
|
||||||
|
|
||||||
private(set) var handledPublicMessageCount = 0
|
private(set) var handledPublicMessageCount = 0
|
||||||
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
||||||
@@ -469,39 +621,63 @@ private final class PerfNostrContext: ChatNostrContext {
|
|||||||
|
|
||||||
// MARK: - Mock ChatDeliveryContext
|
// 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
|
@MainActor
|
||||||
private final class PerfDeliveryContext: ChatDeliveryContext {
|
private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||||
var messages: [BitchatMessage] = []
|
let store = ConversationStore()
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
||||||
var sentReadReceipts: Set<String> = []
|
var sentReadReceipts: Set<String> = []
|
||||||
var isStartupPhase: Bool { false }
|
var isStartupPhase: Bool { false }
|
||||||
|
private var publicIDs: [String] = []
|
||||||
|
private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = []
|
||||||
|
|
||||||
func notifyUIChanged() {}
|
func notifyUIChanged() {}
|
||||||
func markMessageDelivered(_ messageID: String) {}
|
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 {
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||||
let oldCount = sentReadReceipts.count
|
let oldCount = sentReadReceipts.count
|
||||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
return oldCount - sentReadReceipts.count
|
return oldCount - sentReadReceipts.count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
/// `publicCount` public messages (split evenly between mesh and one
|
||||||
/// deterministic IDs and timestamps.
|
/// 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 {
|
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||||
let context = PerfDeliveryContext()
|
let context = PerfDeliveryContext()
|
||||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
context.messages = (0..<publicCount).map { i in
|
let geohashID = ConversationID.geohash("u4pruyd")
|
||||||
BitchatMessage(
|
for i in 0..<publicCount {
|
||||||
|
let message = BitchatMessage(
|
||||||
id: "pub-\(i)",
|
id: "pub-\(i)",
|
||||||
sender: "peer\(i % 20)",
|
sender: "peer\(i % 20)",
|
||||||
content: "public message number \(i)",
|
content: "public message number \(i)",
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
|
context.store.append(message, to: i % 2 == 0 ? .mesh : geohashID)
|
||||||
|
context.publicIDs.append(message.id)
|
||||||
}
|
}
|
||||||
for p in 0..<peerCount {
|
for p in 0..<peerCount {
|
||||||
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
||||||
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
|
var messageIDs: [String] = []
|
||||||
BitchatMessage(
|
for i in 0..<messagesPerPeer {
|
||||||
|
let message = BitchatMessage(
|
||||||
id: "dm-\(p)-\(i)",
|
id: "dm-\(p)-\(i)",
|
||||||
sender: "peer\(p)",
|
sender: "peer\(p)",
|
||||||
content: "private message \(i) for peer \(p)",
|
content: "private message \(i) for peer \(p)",
|
||||||
@@ -511,10 +687,68 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
|||||||
recipientNickname: "me",
|
recipientNickname: "me",
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID
|
||||||
)
|
)
|
||||||
|
context.store.append(message, to: .directPeer(peerID))
|
||||||
|
messageIDs.append(message.id)
|
||||||
}
|
}
|
||||||
|
context.privateIDsByPeer.append((peerID, messageIDs))
|
||||||
}
|
}
|
||||||
return context
|
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
|
// MARK: - Mock MessageFormattingContext
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
// PublicMessagePipelineTests.swift
|
// PublicMessagePipelineTests.swift
|
||||||
// bitchatTests
|
// 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
|
import Testing
|
||||||
@@ -13,14 +16,15 @@ import BitFoundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
|
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
|
||||||
private let dedupService = MessageDeduplicationService()
|
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] {
|
func messages(in conversationID: ConversationID) -> [BitchatMessage] {
|
||||||
messages
|
committed.filter { $0.conversationID == conversationID }.map(\.message)
|
||||||
}
|
|
||||||
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
|
||||||
self.messages = messages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
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) {
|
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||||||
dedupService.recordContentKey(key, timestamp: timestamp)
|
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 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 {
|
struct PublicMessagePipelineTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func flush_sortsByTimestamp() async {
|
func flush_commitsInTimestampOrder() async {
|
||||||
let pipeline = PublicMessagePipeline()
|
let pipeline = PublicMessagePipeline()
|
||||||
let delegate = TestPipelineDelegate()
|
let delegate = TestPipelineDelegate()
|
||||||
pipeline.delegate = delegate
|
pipeline.delegate = delegate
|
||||||
@@ -53,26 +75,13 @@ struct PublicMessagePipelineTests {
|
|||||||
let earlier = Date().addingTimeInterval(-10)
|
let earlier = Date().addingTimeInterval(-10)
|
||||||
let later = Date()
|
let later = Date()
|
||||||
|
|
||||||
let messageA = BitchatMessage(
|
pipeline.enqueue(makeMessage(id: "a", content: "Later", timestamp: later), to: .mesh)
|
||||||
id: "a",
|
pipeline.enqueue(makeMessage(id: "b", content: "Earlier", timestamp: earlier), to: .mesh)
|
||||||
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.flushIfNeeded()
|
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
|
@Test @MainActor
|
||||||
@@ -82,86 +91,41 @@ struct PublicMessagePipelineTests {
|
|||||||
pipeline.delegate = delegate
|
pipeline.delegate = delegate
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let messageA = BitchatMessage(
|
pipeline.enqueue(makeMessage(id: "a", content: "Same", timestamp: now), to: .mesh)
|
||||||
id: "a",
|
pipeline.enqueue(makeMessage(id: "b", content: "Same", timestamp: now.addingTimeInterval(0.2)), to: .mesh)
|
||||||
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.flushIfNeeded()
|
pipeline.flushIfNeeded()
|
||||||
|
|
||||||
#expect(delegate.messages.count == 1)
|
#expect(delegate.messages(in: .mesh).count == 1)
|
||||||
#expect(delegate.messages.first?.content == "Same")
|
#expect(delegate.messages(in: .mesh).first?.content == "Same")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func lateInsert_meshAppendsRecentOlderMessage() async {
|
func flush_routesEachMessageToItsConversation() async {
|
||||||
let pipeline = PublicMessagePipeline()
|
let pipeline = PublicMessagePipeline()
|
||||||
let delegate = TestPipelineDelegate()
|
let delegate = TestPipelineDelegate()
|
||||||
pipeline.delegate = delegate
|
pipeline.delegate = delegate
|
||||||
pipeline.updateActiveChannel(.mesh)
|
|
||||||
|
|
||||||
let base = Date()
|
let base = Date()
|
||||||
let newer = BitchatMessage(
|
pipeline.enqueue(makeMessage(id: "mesh-1", content: "mesh hello", timestamp: base), to: .mesh)
|
||||||
id: "new",
|
// A channel switch mid-batch must not misroute already-buffered messages.
|
||||||
sender: "A",
|
pipeline.enqueue(makeMessage(id: "geo-1", content: "geo hello", timestamp: base.addingTimeInterval(1)), to: .geohash("u4pruydq"))
|
||||||
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.flushIfNeeded()
|
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
|
@Test @MainActor
|
||||||
func lateInsert_locationInsertsByTimestamp() async {
|
func flush_rejectedCommitDoesNotRecordContentKey() async {
|
||||||
let pipeline = PublicMessagePipeline()
|
let pipeline = PublicMessagePipeline()
|
||||||
let delegate = TestPipelineDelegate()
|
let delegate = TestPipelineDelegate()
|
||||||
pipeline.delegate = delegate
|
pipeline.delegate = delegate
|
||||||
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
|
delegate.rejectedMessageIDs = ["dup"]
|
||||||
|
|
||||||
let base = Date()
|
pipeline.enqueue(makeMessage(id: "dup", content: "already stored", timestamp: Date()), to: .mesh)
|
||||||
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.flushIfNeeded()
|
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
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Tests for PrivateChatManager read receipt and selection behavior.
|
// 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
|
import Testing
|
||||||
@@ -12,13 +14,20 @@ import BitFoundation
|
|||||||
|
|
||||||
struct PrivateChatManagerTests {
|
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
|
@Test @MainActor
|
||||||
func startChat_setsSelectedAndClearsUnread() async {
|
func startChat_setsSelectedAndClearsUnread() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
let peerID = PeerID(str: "00000000000000AA")
|
let peerID = PeerID(str: "00000000000000AA")
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
store.append(
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "pm-1",
|
id: "pm-1",
|
||||||
sender: "Peer",
|
sender: "Peer",
|
||||||
@@ -28,9 +37,10 @@ struct PrivateChatManagerTests {
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID
|
||||||
)
|
),
|
||||||
]
|
to: .directPeer(peerID)
|
||||||
manager.unreadMessages.insert(peerID)
|
)
|
||||||
|
store.markUnread(.directPeer(peerID))
|
||||||
|
|
||||||
manager.startChat(with: peerID)
|
manager.startChat(with: peerID)
|
||||||
|
|
||||||
@@ -43,13 +53,13 @@ struct PrivateChatManagerTests {
|
|||||||
func markAsRead_sendsReadReceiptViaRouter() async {
|
func markAsRead_sendsReadReceiptViaRouter() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let router = MessageRouter(transports: [transport])
|
let router = MessageRouter(transports: [transport])
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
manager.messageRouter = router
|
manager.messageRouter = router
|
||||||
|
|
||||||
let peerID = PeerID(str: "00000000000000BB")
|
let peerID = PeerID(str: "00000000000000BB")
|
||||||
transport.reachablePeers.insert(peerID)
|
transport.reachablePeers.insert(peerID)
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
store.append(
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "pm-2",
|
id: "pm-2",
|
||||||
sender: "Peer",
|
sender: "Peer",
|
||||||
@@ -59,9 +69,10 @@ struct PrivateChatManagerTests {
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID
|
||||||
)
|
),
|
||||||
]
|
to: .directPeer(peerID)
|
||||||
manager.unreadMessages.insert(peerID)
|
)
|
||||||
|
store.markUnread(.directPeer(peerID))
|
||||||
|
|
||||||
manager.markAsRead(from: peerID)
|
manager.markAsRead(from: peerID)
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
@@ -74,10 +85,10 @@ struct PrivateChatManagerTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func markAsRead_withoutRouterFallsBackToTransport() async {
|
func markAsRead_withoutRouterFallsBackToTransport() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
let peerID = PeerID(str: "00000000000000CC")
|
let peerID = PeerID(str: "00000000000000CC")
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
store.append(
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "pm-fallback",
|
id: "pm-fallback",
|
||||||
sender: "Peer",
|
sender: "Peer",
|
||||||
@@ -87,8 +98,9 @@ struct PrivateChatManagerTests {
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID
|
||||||
)
|
),
|
||||||
]
|
to: .directPeer(peerID)
|
||||||
|
)
|
||||||
|
|
||||||
manager.markAsRead(from: peerID)
|
manager.markAsRead(from: peerID)
|
||||||
|
|
||||||
@@ -99,7 +111,7 @@ struct PrivateChatManagerTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
|
func consolidateMessages_mergesStableNoiseKeyHistoryAndMarksUnread() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
let identityManager = MockIdentityManager(MockKeychain())
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)
|
let unifiedPeerService = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identityManager)
|
||||||
@@ -120,7 +132,7 @@ struct PrivateChatManagerTests {
|
|||||||
])
|
])
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
|
||||||
manager.privateChats[stablePeerID] = [
|
store.append(
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "stable-msg",
|
id: "stable-msg",
|
||||||
sender: "Alice",
|
sender: "Alice",
|
||||||
@@ -130,9 +142,10 @@ struct PrivateChatManagerTests {
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: stablePeerID
|
senderPeerID: stablePeerID
|
||||||
)
|
),
|
||||||
]
|
to: .directPeer(stablePeerID)
|
||||||
manager.unreadMessages.insert(stablePeerID)
|
)
|
||||||
|
store.markUnread(.directPeer(stablePeerID))
|
||||||
|
|
||||||
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: [])
|
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "Alice", persistedReadReceipts: [])
|
||||||
|
|
||||||
@@ -146,11 +159,11 @@ struct PrivateChatManagerTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {
|
func consolidateMessages_movesTemporaryGeoDMHistoryByNickname() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
let peerID = PeerID(str: "0011223344556677")
|
let peerID = PeerID(str: "0011223344556677")
|
||||||
let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042")
|
let tempPeerID = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000042")
|
||||||
|
|
||||||
manager.privateChats[tempPeerID] = [
|
store.append(
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "geo-msg",
|
id: "geo-msg",
|
||||||
sender: "Alice",
|
sender: "Alice",
|
||||||
@@ -160,9 +173,10 @@ struct PrivateChatManagerTests {
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: tempPeerID
|
senderPeerID: tempPeerID
|
||||||
)
|
),
|
||||||
]
|
to: .directPeer(tempPeerID)
|
||||||
manager.unreadMessages.insert(tempPeerID)
|
)
|
||||||
|
store.markUnread(.directPeer(tempPeerID))
|
||||||
|
|
||||||
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: [])
|
let hadUnread = manager.consolidateMessages(for: peerID, peerNickname: "alice", persistedReadReceipts: [])
|
||||||
|
|
||||||
@@ -177,10 +191,10 @@ struct PrivateChatManagerTests {
|
|||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {
|
func syncReadReceiptsForSentMessages_onlyCopiesDeliveredAndRead() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
let peerID = PeerID(str: "00000000000000DD")
|
let peerID = PeerID(str: "00000000000000DD")
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
let seeded = [
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "sent-read",
|
id: "sent-read",
|
||||||
sender: "Me",
|
sender: "Me",
|
||||||
@@ -215,6 +229,9 @@ struct PrivateChatManagerTests {
|
|||||||
deliveryStatus: .failed(reason: "nope")
|
deliveryStatus: .failed(reason: "nope")
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
for message in seeded {
|
||||||
|
store.append(message, to: .directPeer(peerID))
|
||||||
|
}
|
||||||
|
|
||||||
var externalReceipts = Set<String>()
|
var externalReceipts = Set<String>()
|
||||||
manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts)
|
manager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: "Me", externalReceipts: &externalReceipts)
|
||||||
@@ -223,47 +240,36 @@ struct PrivateChatManagerTests {
|
|||||||
#expect(manager.sentReadReceipts == Set(["sent-read", "sent-delivered"]))
|
#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
|
@Test @MainActor
|
||||||
func sanitizeChat_sortsChronologicallyAndKeepsLatestDuplicate() async {
|
func store_keepsChronologicalOrderAndDedupsByID() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
let manager = PrivateChatManager(meshService: transport)
|
let (manager, store) = Self.makeManager(transport: transport)
|
||||||
let peerID = PeerID(str: "00000000000000EE")
|
let peerID = PeerID(str: "00000000000000EE")
|
||||||
let base = Date(timeIntervalSince1970: 10)
|
let base = Date(timeIntervalSince1970: 10)
|
||||||
|
|
||||||
manager.privateChats[peerID] = [
|
func message(_ id: String, _ content: String, offset: TimeInterval) -> BitchatMessage {
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "same",
|
id: id,
|
||||||
sender: "Peer",
|
sender: "Peer",
|
||||||
content: "Older",
|
content: content,
|
||||||
timestamp: base.addingTimeInterval(10),
|
timestamp: base.addingTimeInterval(offset),
|
||||||
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),
|
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: "Me",
|
recipientNickname: "Me",
|
||||||
senderPeerID: peerID
|
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]?.map(\.id) == ["first", "same"])
|
||||||
#expect(manager.privateChats[peerID]?.last?.content == "Newest")
|
#expect(manager.privateChats[peerID]?.last?.content == "Newest")
|
||||||
|
|||||||
@@ -138,6 +138,46 @@ enum TestError: Error {
|
|||||||
case testFailure(String)
|
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 {
|
func sleep(_ seconds: TimeInterval) async throws {
|
||||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,17 +52,17 @@ private func makeSmokeLocationManager() -> LocationChannelManager {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
|
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
|
||||||
let locationManager = makeSmokeLocationManager()
|
let locationManager = makeSmokeLocationManager()
|
||||||
let conversationStore = viewModel.conversationStore
|
let conversations = viewModel.conversations
|
||||||
let publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
let publicChatModel = PublicChatModel(conversations: conversations)
|
||||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
let privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||||
let appChromeModel = AppChromeModel(
|
let appChromeModel = AppChromeModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
privateInboxModel: privateInboxModel
|
privateInboxModel: privateInboxModel
|
||||||
)
|
)
|
||||||
let privateConversationModel = PrivateConversationModel(
|
let privateConversationModel = PrivateConversationModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: conversations,
|
||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
let verificationModel = VerificationModel(
|
let verificationModel = VerificationModel(
|
||||||
@@ -72,11 +72,11 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
|||||||
let conversationUIModel = ConversationUIModel(
|
let conversationUIModel = ConversationUIModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
privateConversationModel: privateConversationModel,
|
privateConversationModel: privateConversationModel,
|
||||||
conversationStore: conversationStore
|
conversations: conversations
|
||||||
)
|
)
|
||||||
let peerListModel = PeerListModel(
|
let peerListModel = PeerListModel(
|
||||||
chatViewModel: viewModel,
|
chatViewModel: viewModel,
|
||||||
conversationStore: conversationStore,
|
conversations: conversations,
|
||||||
locationChannelsModel: locationChannelsModel
|
locationChannelsModel: locationChannelsModel
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ struct ViewSmokeTests {
|
|||||||
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
|
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
|
||||||
])
|
])
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
viewModel.unreadPrivateMessages.insert(blockedPeer)
|
viewModel.markPrivateChatUnread(blockedPeer)
|
||||||
|
|
||||||
_ = mount(
|
_ = mount(
|
||||||
MeshPeerList(
|
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