mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
ConversationStore maintains an exact messageID -> Set<ConversationID> map at every mutation point (append/upsert/remove/migrate/trim/clear), so delivery updates are ID-only lookups that fan out to mirrored ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119 lines: the positional location index, its growth-detection/rebuild machinery, and the duplicate no-downgrade check are deleted - the rule now lives in exactly one place. The middle-insertion regression tests are rewritten against the store since stale positional locations are structurally impossible now. delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
4.6 KiB
Swift
120 lines
4.6 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// The narrow surface `ChatDeliveryCoordinator` needs from its owner.
|
|
///
|
|
/// Coordinators should depend on the minimal context they actually use rather
|
|
/// than holding an `unowned` back-reference to the whole `ChatViewModel`. This
|
|
/// keeps the coordinator independently testable (see
|
|
/// `ChatDeliveryCoordinatorContextTests`) and makes its true dependencies
|
|
/// explicit. This protocol is the exemplar for migrating the other
|
|
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
|
@MainActor
|
|
protocol ChatDeliveryContext: AnyObject {
|
|
var isStartupPhase: Bool { get }
|
|
/// Applies a delivery status to every copy of the message across
|
|
/// conversations (`ConversationStore` intent, ID-only: the store's
|
|
/// message-ID → conversation map resolves which conversations hold the
|
|
/// message, including mirrored ephemeral/stable private copies). The
|
|
/// no-downgrade rule is enforced in the store. Returns `false` when the
|
|
/// message is unknown or no copy changed.
|
|
@discardableResult
|
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
|
/// Current delivery status of the message in whichever conversation holds it.
|
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
|
/// Message IDs across all direct conversations (read-receipt pruning).
|
|
func privateMessageIDs() -> Set<String>
|
|
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
|
|
/// Returns the number of receipts removed. (Single mutation path for the
|
|
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int
|
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
|
func notifyUIChanged()
|
|
/// Confirms receipt so the message router stops retaining the message for resend.
|
|
func markMessageDelivered(_ messageID: String)
|
|
}
|
|
|
|
extension ChatViewModel: ChatDeliveryContext {
|
|
@discardableResult
|
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
|
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
|
}
|
|
|
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
|
conversations.deliveryStatus(forMessageID: messageID)
|
|
}
|
|
|
|
func privateMessageIDs() -> Set<String> {
|
|
conversations.directMessageIDs()
|
|
}
|
|
|
|
func notifyUIChanged() {
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func markMessageDelivered(_ messageID: String) {
|
|
messageRouter.markDelivered(messageID)
|
|
}
|
|
}
|
|
|
|
/// Thin mapper from delivery events (read receipts, transport delivery
|
|
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
|
|
/// retention cleanup. The store's message-ID → conversation map replaces the
|
|
/// positional `messageLocationIndex` this coordinator used to maintain.
|
|
final class ChatDeliveryCoordinator {
|
|
private unowned let context: any ChatDeliveryContext
|
|
|
|
init(context: any ChatDeliveryContext) {
|
|
self.context = context
|
|
}
|
|
|
|
@MainActor
|
|
func cleanupOldReadReceipts() {
|
|
guard !context.isStartupPhase else { return }
|
|
let validMessageIDs = context.privateMessageIDs()
|
|
guard !validMessageIDs.isEmpty else { return }
|
|
|
|
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
|
if removedCount > 0 {
|
|
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
|
updateMessageDeliveryStatus(
|
|
receipt.originalMessageID,
|
|
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
updateMessageDeliveryStatus(messageID, status: status)
|
|
}
|
|
|
|
@MainActor
|
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
|
context.deliveryStatus(forMessageID: messageID)
|
|
}
|
|
|
|
@MainActor
|
|
@discardableResult
|
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
|
switch status {
|
|
case .delivered, .read:
|
|
// Confirmed receipt — stop retaining the message for resend.
|
|
context.markMessageDelivered(messageID)
|
|
default:
|
|
break
|
|
}
|
|
|
|
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
|
return false
|
|
}
|
|
context.notifyUIChanged()
|
|
return true
|
|
}
|
|
}
|