Cut private message path over to ConversationStore

All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.

ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).

pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 12:19:11 +02:00
co-authored by Claude Fable 5
parent ac3a2f2d34
commit 879d8cba12
33 changed files with 1293 additions and 545 deletions
@@ -14,13 +14,39 @@ import Foundation
@MainActor
protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get set }
var privateChats: [PeerID: [BitchatMessage]] { get }
var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get set }
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
// MARK: Conversation store intents
// The sole mutation paths for private message state (single-writer
// `ConversationStore` ops; see docs/CONVERSATION-STORE-DESIGN.md).
/// Appends a private message in timestamp order; returns `false` on
/// duplicate message ID.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Replace-or-append a private message by ID, keeping its position.
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID)
/// Applies a delivery status by message ID; returns `false` when the
/// message is unknown or the update would downgrade the status.
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
func markPrivateChatUnread(_ peerID: PeerID)
func markPrivateChatRead(_ peerID: PeerID)
/// Removes the peer's chat entirely, including unread state.
func removePrivateChat(_ peerID: PeerID)
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (dedup by ID, order preserved, unread carried, old chat removed).
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
/// `true` when any private chat contains a message with `messageID`.
func privateChatsContainMessage(withID messageID: String) -> Bool
/// `true` when `peerID`'s chat contains a message with `messageID`.
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
@@ -65,10 +91,9 @@ protocol ChatPrivateConversationContext: AnyObject {
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
// MARK: System messages & chat hygiene
// MARK: System messages
func addSystemMessage(_ content: String)
func addMeshOnlySystemMessage(_ content: String)
func sanitizeChat(for peerID: PeerID)
// MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any.
@@ -165,10 +190,6 @@ extension ChatViewModel: ChatPrivateConversationContext {
addSystemMessage(content, timestamp: Date())
}
func sanitizeChat(for peerID: PeerID) {
privateChatManager.sanitizeChat(for: peerID)
}
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
}
@@ -249,10 +270,7 @@ final class ChatPrivateConversationCoordinator {
deliveryStatus: .sending
)
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
context.privateChats[peerID]?.append(message)
context.appendPrivateMessage(message, to: peerID)
context.notifyUIChanged()
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
@@ -262,15 +280,15 @@ final class ChatPrivateConversationCoordinator {
recipientNickname: recipientNickname ?? "user",
messageID: messageID
)
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[idx].deliveryStatus = .sent
}
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
} else {
if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[index].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
)
}
),
forMessageID: messageID,
peerID: peerID
)
let name = recipientNickname ?? "user"
context.addSystemMessage(
String(
@@ -303,28 +321,28 @@ final class ChatPrivateConversationCoordinator {
deliveryStatus: .sending
)
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
context.privateChats[peerID]?.append(message)
context.appendPrivateMessage(message, to: peerID)
context.notifyUIChanged()
guard let recipientHex = context.nostrKeyMapping[peerID] else {
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
)
}
),
forMessageID: messageID,
peerID: peerID
)
return
}
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
)
}
),
forMessageID: messageID,
peerID: peerID
)
context.addSystemMessage(
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
)
@@ -334,11 +352,13 @@ final class ChatPrivateConversationCoordinator {
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
)
}
),
forMessageID: messageID,
peerID: peerID
)
return
}
@@ -352,15 +372,15 @@ final class ChatPrivateConversationCoordinator {
from: identity,
messageID: messageID
)
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
} catch {
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
)
}
),
forMessageID: messageID,
peerID: peerID
)
}
}
@@ -382,10 +402,7 @@ final class ChatPrivateConversationCoordinator {
return
}
if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) {
return
}
if context.privateChatsContainMessage(withID: messageId) { return }
let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage(
@@ -400,17 +417,14 @@ final class ChatPrivateConversationCoordinator {
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
if context.privateChats[convKey] == nil {
context.privateChats[convKey] = []
}
context.privateChats[convKey]?.append(message)
context.appendPrivateMessage(message, to: convKey)
let isViewing = context.selectedPrivateChatPeer == convKey
let wasReadBefore = context.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread {
context.unreadPrivateMessages.insert(convKey)
context.markPrivateChatUnread(convKey)
}
if isViewing {
@@ -427,10 +441,11 @@ final class ChatPrivateConversationCoordinator {
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[convKey]?[idx].deliveryStatus = .delivered(
to: context.displayNameForNostrPubkey(senderPubkey),
at: Date()
if context.privateChat(convKey, containsMessageWithID: messageID) {
context.setPrivateDeliveryStatus(
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
forMessageID: messageID,
peerID: convKey
)
context.notifyUIChanged()
SecureLogger.info(
@@ -445,10 +460,11 @@ final class ChatPrivateConversationCoordinator {
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
context.privateChats[convKey]?[idx].deliveryStatus = .read(
by: context.displayNameForNostrPubkey(senderPubkey),
at: Date()
if context.privateChat(convKey, containsMessageWithID: messageID) {
context.setPrivateDeliveryStatus(
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
forMessageID: messageID,
peerID: convKey
)
context.notifyUIChanged()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
@@ -575,17 +591,9 @@ final class ChatPrivateConversationCoordinator {
if stableKeyHex != peerID,
let nostrMessages = context.privateChats[stableKeyHex],
!nostrMessages.isEmpty {
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
let existingMessageIds = Set(context.privateChats[peerID]?.map { $0.id } ?? [])
for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) {
context.privateChats[peerID]?.append(nostrMessage)
}
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
context.privateChats.removeValue(forKey: stableKeyHex)
// Store migration dedups by ID, keeps timestamp order, and
// removes the stable-key chat.
context.migratePrivateChat(from: stableKeyHex, to: peerID)
SecureLogger.info(
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
@@ -612,33 +620,23 @@ final class ChatPrivateConversationCoordinator {
context.sendMeshReadReceipt(receipt, to: peerID)
context.markReadReceiptSent(message.id)
} else {
context.unreadPrivateMessages.insert(peerID)
context.markPrivateChatUnread(peerID)
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
}
context.notifyUIChanged()
}
/// O(1)-per-conversation dedup via the store's message-ID indexes
/// (replaces the full scan over every private chat).
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
if context.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
return true
}
for (_, messages) in context.privateChats where messages.contains(where: { $0.id == messageId }) {
return true
}
return false
context.privateChatsContainMessage(withID: messageId)
}
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
if context.privateChats[targetPeerID] == nil {
context.privateChats[targetPeerID] = []
}
if let idx = context.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
context.privateChats[targetPeerID]?[idx] = message
} else {
context.privateChats[targetPeerID]?.append(message)
}
context.sanitizeChat(for: targetPeerID)
// Store upsert replaces in place by message ID or inserts in
// timestamp order; the old per-append sanitize re-sort is obsolete.
context.upsertPrivateMessage(message, in: targetPeerID)
}
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
@@ -649,15 +647,7 @@ final class ChatPrivateConversationCoordinator {
return
}
if context.privateChats[ephemeralPeerID] == nil {
context.privateChats[ephemeralPeerID] = []
}
if let idx = context.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
context.privateChats[ephemeralPeerID]?[idx] = message
} else {
context.privateChats[ephemeralPeerID]?.append(message)
}
context.sanitizeChat(for: ephemeralPeerID)
context.upsertPrivateMessage(message, in: ephemeralPeerID)
}
func handleViewingThisChat(
@@ -666,10 +656,10 @@ final class ChatPrivateConversationCoordinator {
key: Data?,
senderPubkey: String
) {
context.unreadPrivateMessages.remove(targetPeerID)
context.markPrivateChatRead(targetPeerID)
if let key,
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
context.unreadPrivateMessages.remove(ephemeralPeerID)
context.markPrivateChatRead(ephemeralPeerID)
}
guard !context.sentReadReceipts.contains(message.id) else { return }
@@ -702,11 +692,11 @@ final class ChatPrivateConversationCoordinator {
) {
guard shouldMarkAsUnread else { return }
context.unreadPrivateMessages.insert(targetPeerID)
context.markPrivateChatUnread(targetPeerID)
if let key,
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
ephemeralPeerID != targetPeerID {
context.unreadPrivateMessages.insert(ephemeralPeerID)
context.markPrivateChatUnread(ephemeralPeerID)
}
if isRecentMessage {
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
@@ -778,8 +768,13 @@ final class ChatPrivateConversationCoordinator {
let currentFingerprint = context.getFingerprint(for: peerID)
if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
// Chats migrated wholesale go through the store's
// `migrateConversation` intent; partially-migrated chats keep
// their non-recent tail, so the recent messages are copied in
// via ordered append (dedup by ID) instead.
var partiallyMigratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [PeerID] = []
var didMigrate = false
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
@@ -790,10 +785,11 @@ final class ChatPrivateConversationCoordinator {
if let currentFp = currentFingerprint,
let oldFp = oldFingerprint,
currentFp == oldFp {
migratedMessages.append(contentsOf: recentMessages)
didMigrate = true
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
partiallyMigratedMessages.append(contentsOf: recentMessages)
SecureLogger.info(
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
category: .session
@@ -811,9 +807,11 @@ final class ChatPrivateConversationCoordinator {
}
if isRelevantChat {
migratedMessages.append(contentsOf: recentMessages)
didMigrate = true
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
partiallyMigratedMessages.append(contentsOf: recentMessages)
}
SecureLogger.warning(
@@ -826,21 +824,22 @@ final class ChatPrivateConversationCoordinator {
if !oldPeerIDsToRemove.isEmpty {
for oldID in oldPeerIDsToRemove {
context.privateChats.removeValue(forKey: oldID)
context.unreadPrivateMessages.remove(oldID)
// The old behavior dropped the unread flag of removed
// chats instead of transferring it; clear it before the
// migration so the store doesn't carry it over.
context.markPrivateChatRead(oldID)
context.migratePrivateChat(from: oldID, to: peerID)
context.clearStoredFingerprint(for: oldID)
}
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
}
if !migratedMessages.isEmpty {
if context.privateChats[peerID] == nil {
context.privateChats[peerID] = []
}
context.privateChats[peerID]?.append(contentsOf: migratedMessages)
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
context.sanitizeChat(for: peerID)
for message in partiallyMigratedMessages {
context.appendPrivateMessage(message, to: peerID)
}
if didMigrate {
context.notifyUIChanged()
}
}