mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 08:45:19 +00:00
Cut public message path over to ConversationStore; delete PublicTimelineStore
Mesh and geohash timelines are now store conversations. All public mutation sites flow through store intents; PublicMessagePipeline keeps its 80ms UI batching but commits batches via store appends with each buffered entry carrying its destination conversation (a mid-batch channel switch now flushes instead of dropping the buffer). ChatViewModel.messages becomes a cached get-only view of the active conversation, invalidated through the change subject. The mesh late-insert threshold is consciously removed: it only ever ordered the non-rendered messages copy, so strict timestamp insertion makes the working set agree with rendered order. PublicTimelineStore and the per-message full-array legacy sync are deleted; the coalescing bridge mirrors public conversations for the remaining legacy readers. pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady; store.append 237k/s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,23 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
return removed
|
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() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
@@ -346,6 +363,16 @@ final class ConversationStore: ObservableObject {
|
|||||||
return removed
|
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 }
|
||||||
|
for messageID in conversation.removeAll(where: predicate) {
|
||||||
|
changes.send(.messageRemoved(id, messageID: messageID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Empties a conversation's timeline but keeps the conversation (and
|
/// Empties a conversation's timeline but keeps the conversation (and
|
||||||
/// its unread/selection state) alive.
|
/// its unread/selection state) alive.
|
||||||
func clear(_ id: ConversationID) {
|
func clear(_ id: ConversationID) {
|
||||||
@@ -464,3 +491,26 @@ extension ConversationStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Migration step 3 compatibility (public timeline derived views)
|
||||||
|
|
||||||
|
extension ConversationStore {
|
||||||
|
/// Removes a message by ID from whichever public (mesh/geohash)
|
||||||
|
/// conversation contains it — the compat shape of the legacy
|
||||||
|
/// `PublicTimelineStore.removeMessage(withID:)`. Returns the removed
|
||||||
|
/// message, if any.
|
||||||
|
@discardableResult
|
||||||
|
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||||
|
for (id, conversation) in conversationsByID {
|
||||||
|
switch id {
|
||||||
|
case .mesh, .geohash:
|
||||||
|
if conversation.containsMessage(withID: messageID) {
|
||||||
|
return removeMessage(withID: messageID, from: id)
|
||||||
|
}
|
||||||
|
case .direct:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,18 +5,20 @@
|
|||||||
// Migration step 2 adapter (DELETE IN STEP 5, see
|
// Migration step 2 adapter (DELETE IN STEP 5, see
|
||||||
// docs/CONVERSATION-STORE-DESIGN.md §4).
|
// docs/CONVERSATION-STORE-DESIGN.md §4).
|
||||||
//
|
//
|
||||||
// The new `ConversationStore` is the single writer for private (direct)
|
// The new `ConversationStore` is the single writer for private (direct) AND
|
||||||
// message state; the feature models (`PrivateInboxModel`,
|
// public (mesh/geohash) message state; the feature models
|
||||||
// `PrivateConversationModel`, `ConversationUIModel`, `PeerListModel`) still
|
// (`PrivateInboxModel`, `PrivateConversationModel`, `ConversationUIModel`,
|
||||||
// read the replace-based `LegacyConversationStore` until step 5. This bridge
|
// `PeerListModel`, `PublicChatModel`) still read the replace-based
|
||||||
// keeps Legacy fed from the new store's `changes` subject: per-message
|
// `LegacyConversationStore` until step 5. This bridge keeps Legacy fed from
|
||||||
// changes mark the affected conversation dirty and a `Task.yield`-coalesced
|
// the new store's `changes` subject: per-message changes mark the affected
|
||||||
// flush mirrors only the dirty conversations — a burst of N appends costs
|
// conversation dirty and a `Task.yield`-coalesced flush mirrors only the
|
||||||
// ONE Legacy replace (like the old debounced sync) without the full-dict
|
// dirty conversations — a burst of N appends costs ONE Legacy replace (like
|
||||||
// pass. Structural changes (migration/removal) resynchronize immediately.
|
// the old debounced sync) without the full-dict pass. Structural direct
|
||||||
// Legacy is therefore eventually consistent within one run-loop tick — the
|
// changes (migration/removal) resynchronize immediately; public removals
|
||||||
// same visibility the old `$privateChats` sink provided — while the new
|
// mirror an empty timeline. Legacy is therefore eventually consistent within
|
||||||
// store stays synchronously authoritative.
|
// one run-loop tick — the same visibility the old sinks and per-message
|
||||||
|
// public syncs provided — while the new store stays synchronously
|
||||||
|
// authoritative.
|
||||||
//
|
//
|
||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
@@ -58,9 +60,10 @@ final class LegacyConversationStoreBridge {
|
|||||||
/// `synchronizePrivateChats` full pass — acceptable because it only runs
|
/// `synchronizePrivateChats` full pass — acceptable because it only runs
|
||||||
/// on peer-list changes and rare migrations, never per message.
|
/// on peer-list changes and rare migrations, never per message.
|
||||||
func resynchronizeAll() {
|
func resynchronizeAll() {
|
||||||
// The full pass covers every conversation; pending per-conversation
|
// The full pass covers every direct conversation; pending direct
|
||||||
// work is redundant.
|
// per-conversation work is redundant. Dirty public conversations
|
||||||
dirtyConversations.removeAll()
|
// keep their scheduled mirror.
|
||||||
|
dirtyConversations = dirtyConversations.filter { !isDirect($0) }
|
||||||
legacyStore.synchronizePrivateChats(
|
legacyStore.synchronizePrivateChats(
|
||||||
store.directMessagesByRoutingPeerID(),
|
store.directMessagesByRoutingPeerID(),
|
||||||
unreadPeerIDs: store.unreadDirectRoutingPeerIDs(),
|
unreadPeerIDs: store.unreadDirectRoutingPeerIDs(),
|
||||||
@@ -92,13 +95,19 @@ private extension LegacyConversationStoreBridge {
|
|||||||
resynchronizeAll()
|
resynchronizeAll()
|
||||||
|
|
||||||
case .removed(let id):
|
case .removed(let id):
|
||||||
guard isDirect(id) else { return }
|
if isDirect(id) {
|
||||||
resynchronizeAll()
|
resynchronizeAll()
|
||||||
|
} else {
|
||||||
|
// Public conversation removed (panic clear): mirror an empty
|
||||||
|
// timeline immediately so Legacy readers never show stale
|
||||||
|
// messages.
|
||||||
|
dirtyConversations.remove(id)
|
||||||
|
legacyStore.replaceMessages([], for: id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func markDirty(_ id: ConversationID) {
|
func markDirty(_ id: ConversationID) {
|
||||||
guard isDirect(id) else { return }
|
|
||||||
dirtyConversations.insert(id)
|
dirtyConversations.insert(id)
|
||||||
scheduleFlush()
|
scheduleFlush()
|
||||||
}
|
}
|
||||||
@@ -127,16 +136,21 @@ private extension LegacyConversationStoreBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mirrorConversation(_ id: ConversationID) {
|
func mirrorConversation(_ id: ConversationID) {
|
||||||
guard case .direct(let handle) = id,
|
guard let conversation = store.conversationsByID[id] else {
|
||||||
let conversation = store.conversationsByID[id] else {
|
// Removed while dirty; the removal already resynchronized
|
||||||
// Removed while dirty; the removal already resynchronized.
|
// (direct) or mirrored an empty timeline (public).
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
legacyStore.replaceDirectMessages(
|
switch id {
|
||||||
conversation.messages,
|
case .direct(let handle):
|
||||||
for: handle.routingPeerID,
|
legacyStore.replaceDirectMessages(
|
||||||
identityResolver: identityResolver
|
conversation.messages,
|
||||||
)
|
for: handle.routingPeerID,
|
||||||
|
identityResolver: identityResolver
|
||||||
|
)
|
||||||
|
case .mesh, .geohash:
|
||||||
|
legacyStore.replaceMessages(conversation.messages, for: id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDirect(_ id: ConversationID) -> Bool {
|
func isDirect(_ id: ConversationID) -> Bool {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ 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 }
|
/// Get-only derived views of the `ConversationStore`. `BitchatMessage`
|
||||||
|
/// is a reference type, so the public-timeline status patch below writes
|
||||||
|
/// through the shared message objects; step 4 replaces this with
|
||||||
|
/// `setDeliveryStatus(for:in:)` store intents.
|
||||||
|
var messages: [BitchatMessage] { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get }
|
var privateChats: [PeerID: [BitchatMessage]] { get }
|
||||||
var isStartupPhase: Bool { get }
|
var isStartupPhase: Bool { get }
|
||||||
/// Applies a delivery status to a private message by ID (single-writer
|
/// Applies a delivery status to a private message by ID (single-writer
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
/// Appends a private message via the single-writer store intent.
|
/// Appends a private message via the single-writer store intent.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
||||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
|
/// Appends a public message via the single-writer store intent
|
||||||
func refreshVisibleMessages(from channel: ChannelID?)
|
/// (immediate: outgoing media placeholders must render without batching).
|
||||||
func trimMessagesIfNeeded()
|
@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()`).
|
||||||
@@ -52,8 +53,7 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
||||||
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
||||||
// `currentPublicSender()`, `privateChats`,
|
// `currentPublicSender()`, `privateChats`,
|
||||||
// `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
|
||||||
@@ -232,7 +232,6 @@ final class ChatMediaTransferCoordinator {
|
|||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
context.appendPrivateMessage(message, to: peerID)
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
context.trimMessagesIfNeeded()
|
|
||||||
} else {
|
} else {
|
||||||
let (displayName, senderPeerID) = context.currentPublicSender()
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
||||||
message = BitchatMessage(
|
message = BitchatMessage(
|
||||||
@@ -246,9 +245,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(
|
||||||
|
|||||||
@@ -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,25 +30,26 @@ 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 synchronizeConversationSelectionStore()
|
|
||||||
|
|
||||||
// MARK: Private chats (block cleanup & message removal)
|
// MARK: Private chats (block cleanup & message removal)
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get }
|
var privateChats: [PeerID: [BitchatMessage]] { get }
|
||||||
/// Removes the peer's chat entirely, including unread state
|
/// Removes the peer's chat entirely, including unread state
|
||||||
@@ -84,7 +84,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
|
||||||
@@ -100,55 +102,21 @@ protocol ChatPublicConversationContext: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatPublicConversationContext {
|
extension ChatViewModel: ChatPublicConversationContext {
|
||||||
// `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`,
|
// `privateChats`, `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()
|
||||||
@@ -174,8 +142,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 {
|
||||||
@@ -247,11 +215,7 @@ 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)
|
let conversationPeerID = PeerID(nostr_: hex)
|
||||||
@@ -313,16 +277,7 @@ 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 }) {
|
|
||||||
removedMessage = context.messages.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let storeRemoved = context.removeTimelineMessage(withID: messageID) {
|
|
||||||
removedMessage = removedMessage ?? storeRemoved
|
|
||||||
synchronizeAllPublicConversationStores()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
|
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
|
||||||
removedMessage = removedMessage ?? removedPrivateMessage
|
removedMessage = removedMessage ?? removedPrivateMessage
|
||||||
@@ -335,45 +290,8 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
context.notifyUIChanged()
|
context.notifyUIChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeConversationStore() {
|
|
||||||
context.setConversationActiveChannel(context.activeChannel)
|
|
||||||
synchronizePublicConversationStore(for: context.activeChannel)
|
|
||||||
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 {
|
||||||
@@ -413,7 +331,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) {
|
||||||
@@ -423,11 +341,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) {
|
||||||
@@ -437,12 +351,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) {
|
||||||
@@ -492,7 +403,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)
|
||||||
@@ -501,20 +413,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
|
||||||
@@ -522,11 +440,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,14 +507,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)
|
||||||
}
|
}
|
||||||
@@ -604,8 +519,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) {
|
||||||
|
|||||||
@@ -119,9 +119,24 @@ 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` (migration step 3 shim; views
|
||||||
|
/// observe `Conversation` objects directly in step 5). Hot for rendering,
|
||||||
|
/// so 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 {
|
||||||
@@ -298,9 +313,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
get { conversationStore.activeChannel }
|
get { conversationStore.activeChannel }
|
||||||
set {
|
set {
|
||||||
guard conversationStore.activeChannel != newValue else { return }
|
guard conversationStore.activeChannel != newValue else { return }
|
||||||
publicMessagePipeline.updateActiveChannel(newValue)
|
|
||||||
conversationStore.setActiveChannel(newValue)
|
conversationStore.setActiveChannel(newValue)
|
||||||
synchronizePublicConversationStore(for: newValue)
|
visibleMessagesCache = nil
|
||||||
synchronizeConversationSelectionStore()
|
synchronizeConversationSelectionStore()
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
@@ -352,11 +366,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 {
|
||||||
@@ -631,6 +640,83 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
return removed
|
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
|
||||||
@@ -717,12 +803,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Republish on every store change so SwiftUI observers of the
|
// Republish on every store change so SwiftUI observers of the
|
||||||
// view model refresh. This replaces the UI-update role of the old
|
// view model refresh. This replaces the UI-update role of the old
|
||||||
// `PrivateChatManager.@Published` dictionaries (their debounced
|
// `PrivateChatManager.@Published` dictionaries and the old
|
||||||
// Legacy synchronization sinks are gone; the bridge above feeds
|
// `@Published var messages` (their debounced Legacy synchronization
|
||||||
// Legacy instead).
|
// sinks are gone; the bridge above feeds Legacy instead). Changes
|
||||||
|
// touching the ACTIVE public conversation also invalidate the
|
||||||
|
// derived `messages` cache before observers re-read it.
|
||||||
conversations.changes
|
conversations.changes
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] change in
|
||||||
self?.objectWillChange.send()
|
guard let self else { return }
|
||||||
|
if self.changeAffectsActivePublicConversation(change) {
|
||||||
|
self.visibleMessagesCache = nil
|
||||||
|
}
|
||||||
|
self.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
@@ -1008,13 +1100,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()
|
||||||
)
|
|
||||||
conversations.removeAllDirectConversations()
|
|
||||||
|
|
||||||
// Delete all keychain data (including Noise and Nostr keys)
|
// Delete all keychain data (including Noise and Nostr keys)
|
||||||
_ = keychain.deleteAllKeychainData()
|
_ = keychain.deleteAllKeychainData()
|
||||||
@@ -1190,22 +1280,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func initializeConversationStore() {
|
func initializeConversationStore() {
|
||||||
publicConversationCoordinator.initializeConversationStore()
|
conversationStore.setActiveChannel(activeChannel)
|
||||||
}
|
synchronizeConversationSelectionStore()
|
||||||
|
|
||||||
@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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Legacy-store resynchronization from the new `ConversationStore`.
|
/// Full Legacy-store resynchronization from the new `ConversationStore`.
|
||||||
@@ -1226,15 +1302,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimMessagesIfNeeded() {
|
/// Invalidates the derived `messages` cache and notifies observers.
|
||||||
if messages.count > maxMessages {
|
/// (Formerly pulled the channel's timeline into a stored `messages`
|
||||||
messages = Array(messages.suffix(maxMessages))
|
/// 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
|
||||||
@@ -1276,15 +1371,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? {
|
||||||
|
|||||||
@@ -131,7 +131,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,
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,23 +52,17 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
var meshTimeline: [BitchatMessage] = []
|
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
|
||||||
private(set) var refreshedChannels: [ChannelID?] = []
|
|
||||||
private(set) var trimCount = 0
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
@@ -129,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)
|
||||||
}
|
}
|
||||||
@@ -210,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,92 +41,59 @@ 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 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 synchronizeConversationSelectionStore() {
|
|
||||||
selectionStoreSyncCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private chats
|
// Private chats
|
||||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
var unreadPrivateMessages: Set<PeerID> = []
|
var unreadPrivateMessages: Set<PeerID> = []
|
||||||
@@ -222,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 {
|
||||||
@@ -238,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? {
|
||||||
@@ -309,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"])
|
||||||
}
|
}
|
||||||
@@ -340,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,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",
|
||||||
@@ -381,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
|
||||||
@@ -396,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]
|
||||||
@@ -406,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))
|
||||||
@@ -424,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")
|
||||||
@@ -477,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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +264,7 @@ struct ChatViewModelDeliveryStatusTests {
|
|||||||
senderPeerID: transport.myPeerID,
|
senderPeerID: transport.myPeerID,
|
||||||
deliveryStatus: .sent
|
deliveryStatus: .sent
|
||||||
)
|
)
|
||||||
]
|
])
|
||||||
viewModel.seedPrivateChat([
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -709,10 +709,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 +728,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,7 +1013,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",
|
||||||
@@ -1007,7 +1021,7 @@ struct ChatViewModelPanicTests {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false
|
isRelay: false
|
||||||
)
|
)
|
||||||
]
|
])
|
||||||
viewModel.seedPrivateChat([
|
viewModel.seedPrivateChat([
|
||||||
BitchatMessage(
|
BitchatMessage(
|
||||||
id: "pm-1",
|
id: "pm-1",
|
||||||
|
|||||||
@@ -468,4 +468,98 @@ struct ConversationStoreTests {
|
|||||||
store.append(makeMessage(id: "m3", timestamp: 3), to: b)
|
store.append(makeMessage(id: "m3", timestamp: 3), to: b)
|
||||||
#expect(bWillChangeCount > 0)
|
#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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,4 +174,73 @@ struct LegacyConversationStoreBridgeTests {
|
|||||||
#expect(legacy.directMessagesByPeerID()[oldPeerID] ?? [] == [])
|
#expect(legacy.directMessagesByPeerID()[oldPeerID] ?? [] == [])
|
||||||
#expect(legacy.directMessagesByPeerID()[newPeerID]?.map(\.id) == ["coord-mig-1"])
|
#expect(legacy.directMessagesByPeerID()[newPeerID]?.map(\.id) == ["coord-mig-1"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public mirroring (migration step 3)
|
||||||
|
|
||||||
|
@Test("public messages mirror into Legacy via the coalesced flush")
|
||||||
|
@MainActor
|
||||||
|
func publicMessagesMirrorIntoLegacy() async {
|
||||||
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||||
|
|
||||||
|
viewModel.appendPublicMessage(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "bridge-pub-1",
|
||||||
|
sender: "alice",
|
||||||
|
content: "hello mesh",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
viewModel.appendGeohashMessageIfAbsent(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "bridge-geo-1",
|
||||||
|
sender: "bob#abcd",
|
||||||
|
content: "hello geohash",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
toGeohash: "U4PRUYD"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The new store is synchronously authoritative (geohash keys are
|
||||||
|
// normalized to lowercase).
|
||||||
|
#expect(store.conversation(for: .mesh).messages.map(\.id) == ["bridge-pub-1"])
|
||||||
|
#expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["bridge-geo-1"])
|
||||||
|
|
||||||
|
// Legacy catches up within one coalesced flush.
|
||||||
|
let mirrored = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-1"]
|
||||||
|
&& legacy.messages(for: .geohash("u4pruyd")).map(\.id) == ["bridge-geo-1"]
|
||||||
|
},
|
||||||
|
timeout: 1.0
|
||||||
|
)
|
||||||
|
#expect(mirrored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("removing a public conversation empties its Legacy mirror immediately")
|
||||||
|
@MainActor
|
||||||
|
func publicConversationRemovalClearsLegacy() async {
|
||||||
|
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||||
|
viewModel.appendPublicMessage(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "bridge-pub-2",
|
||||||
|
sender: "alice",
|
||||||
|
content: "soon gone",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
let mirrored = await TestHelpers.waitUntil(
|
||||||
|
{ legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-2"] },
|
||||||
|
timeout: 1.0
|
||||||
|
)
|
||||||
|
#expect(mirrored)
|
||||||
|
|
||||||
|
// Panic-style removal: Legacy must never show stale public messages.
|
||||||
|
store.removeConversation(.mesh)
|
||||||
|
#expect(legacy.messages(for: .mesh).isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,9 +389,9 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
/// Baseline for the full public-message ingest cycle through a real
|
/// Baseline for the full public-message ingest cycle through a real
|
||||||
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
|
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
|
||||||
/// main-actor Task hop per message) → `handlePublicMessage` (rate limit,
|
/// main-actor Task hop per message) → `handlePublicMessage` (rate limit,
|
||||||
/// `PublicTimelineStore` append, per-message full-array
|
/// pipeline enqueue) → `PublicMessagePipeline` timer-batched flush into
|
||||||
/// `LegacyConversationStore` sync) → `PublicMessagePipeline` timer-batched
|
/// the `ConversationStore` (derived `ChatViewModel.messages` view) →
|
||||||
/// flush into `ChatViewModel.messages` → `PublicChatModel` mirror.
|
/// coalesced `LegacyConversationStoreBridge` mirror → `PublicChatModel`.
|
||||||
/// Measures until `messages` and the feature model reflect every message,
|
/// Measures until `messages` and the feature model reflect every message,
|
||||||
/// so the pipeline's flush latency is part of the cycle. Senders are
|
/// so the pipeline's flush latency is part of the cycle. Senders are
|
||||||
/// spread 4-per-peer to stay under the 5-token sender rate bucket.
|
/// spread 4-per-peer to stay under the 5-token sender rate bucket.
|
||||||
@@ -567,13 +567,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 }
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -153,6 +153,24 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Test-only: drops every private chat and unread flag.
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearAllPrivateChats() {
|
func clearAllPrivateChats() {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Conversation Store: Single Source of Truth
|
# Conversation Store: Single Source of Truth
|
||||||
|
|
||||||
**Status:** Approved design, not yet implemented. Baselines recorded in
|
**Status:** Steps 1–3 implemented (additive store, private cutover, public
|
||||||
|
cutover; `PublicTimelineStore` deleted). Baselines recorded in
|
||||||
`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`,
|
`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`,
|
||||||
`pipeline.publicIngest`).
|
`pipeline.publicIngest`, `store.append`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user