mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:25:22 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user