mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:45:20 +00:00
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed, well-scoped findings; deeper architectural/security items are tracked separately. - PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s 150ms retry pause left the mic live and streaming for up to 120s, because cancel() no-op'd once `completed` was set. Bail after the sleep if the hold was released, and make cancel() always tear down a late-started capture. - Bridge courier depositDrop reported success and burned the dedup slot before the drop was actually published (evicted/compose-fail = lying 📦 "carried" with no retry). Only consume publishedDropKeys on durable accept; add BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey). - Blocked senders resurfaced via archived "heard here earlier" echoes, the one path that bypassed the live block filter — filter at seed time. - A late optimistic .sent clobbered the router's .carried state; extend ConversationStore.shouldSkipStatusUpdate to a full precedence guard (sending < sent < carried < delivered < read). - Read receipts were permanently burned when the router dropped them (marked sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only record as sent on a successful route, else retry on the next read scan. - MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a peer that never reconnects sat on .sending until relaunch — run it in the 120s bridge sweep. - Sightings tally now rolls over at midnight while idle; wave notification action localized across all 29 locales; bridged anon#tag uses suffix(4) like everything else; makeThrowawayIdentity delegates to NostrIdentity.generate(); .swiftlint.yml excludes .claude worktrees. - Add regression tests: carried→sent no-downgrade, carried→delivered upgrade, evicted pending drop stays retryable. - Bump MARKETING_VERSION to 1.7.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage The stricter no-downgrade guard (delivered/carried never regress to sent) broke two things the earlier commit didn't catch locally (perf tests are skipped in the default run, and Periphery runs only in CI): - PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered assuming both directions apply; the delivered -> sent half is now correctly skipped, so the pass measured 0 updates. Alternate two delivered timestamps instead — every update is real, no downgrade. - Routing the geoDM "not in a location channel" error into the thread removed the only caller of ChatPrivateConversationContext.addSystemMessage, leaving it (and its mock) dead per Periphery. Drop the protocol requirement, the mock impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is compile-time enforced: the context can no longer emit a public system line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge - Read receipts: claim the receipt in sentReadReceipts synchronously before spawning the routing task (chat open runs two read scans in one MainActor stretch, so the async insert let every unread message route twice), and release the claim when the route fails so the retry-on-failed-route behavior is preserved. - Delivery status: extend the no-downgrade guard so the `.sending` stamp a pre-handshake resend emits can no longer clobber carried/delivered/read (the 📦 indicator survived `.sent` but not `.sending`). - Archived echoes: blocking a peer now purges their carried public messages from the gossip archive at block time (UnifiedPeerService and /block), while the fingerprint-to-peerID mapping is still known — the seed-time filter can't resolve offline non-favorite strangers and stays only as defense-in-depth. New Transport hook (default no-op) + GossipSyncManager.removePublicMessages with immediate persist. - Bridge courier: an envelope that can't encode within the drop size caps fails identically on every attempt; consume the dedup slot so the 120s retry sweep stops re-running Noise sealing on it. - MeshSightingsTracker: cache the day-key DateFormatter instead of building one per call. Tests: double-markAsRead dedup + failed-route retry, carried→sending no-downgrade matrix, block-time purge (manager + service wiring), oversize-drop slot consumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Also skip the sent→sending downgrade in the delivery-status guard Codex review follow-up: sendPrivateMessage without an established Noise session emits `.sending` asynchronously, so it can land after the message already reached `.sent` and visibly walk "Sent" back to "Sending...". Treat `.sending` as weaker than `.sent` too — the status was already truthful. `.failed` → `.sending` stays allowed so a retry after a real failure remains visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retain Codable properties in Periphery scan (same fix as #1421) The noiseKey assign-only false positive fired persistently on this branch (twice, including a rerun) despite the baselined USR. Byte-identical to the fix on fix/announce-replay-link-steal so the branches merge cleanly in either order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
12 KiB
Swift
288 lines
12 KiB
Swift
//
|
|
// PrivateChatManager.swift
|
|
// bitchat
|
|
//
|
|
// Manages private chat sessions and messages
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import BitLogger
|
|
import BitFoundation
|
|
import Combine
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Manages private chat session policy (selection, read receipts,
|
|
/// consolidation). Message storage lives in the single-writer
|
|
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
|
|
/// `privateChats` / `unreadMessages` properties below are read-only views
|
|
/// derived from it.
|
|
@MainActor
|
|
final class PrivateChatManager: ObservableObject {
|
|
/// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the
|
|
/// store is the sole owner of conversation selection. Kept `@Published`
|
|
/// so existing observers (`objectWillChange` forwarding into
|
|
/// `ChatViewModel`) keep firing on selection changes. Mutate via
|
|
/// `startChat(with:)` / `endChat()`, which route through the store's
|
|
/// `setSelectedPrivatePeer` intent.
|
|
@Published private(set) var selectedPeer: PeerID? = nil
|
|
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
|
|
|
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
|
|
|
weak var meshService: Transport?
|
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
|
weak var messageRouter: MessageRouter?
|
|
// Peer service for looking up peer info during consolidation
|
|
weak var unifiedPeerService: UnifiedPeerService?
|
|
/// Single source of truth for message and selection state; injected by
|
|
/// the bootstrapper (`wireServiceGraph`).
|
|
var conversationStore: ConversationStore? {
|
|
didSet { bindSelectionMirror() }
|
|
}
|
|
|
|
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
|
|
self.meshService = meshService
|
|
self.conversationStore = conversationStore
|
|
bindSelectionMirror() // didSet does not fire during init
|
|
}
|
|
|
|
/// Keeps `selectedPeer` in lock-step with the store's selection axis
|
|
/// (including store-internal handoffs such as conversation migration).
|
|
private func bindSelectionMirror() {
|
|
guard let store = conversationStore else {
|
|
selectedPeerMirrorCancellable = nil
|
|
return
|
|
}
|
|
selectedPeerMirrorCancellable = store.$selectedPrivatePeerID
|
|
.sink { [weak self] peerID in
|
|
guard let self, self.selectedPeer != peerID else { return }
|
|
self.selectedPeer = peerID
|
|
}
|
|
}
|
|
|
|
// MARK: - Derived message state (read-only compat views)
|
|
|
|
/// All private chats keyed by routing peer ID, derived from the store.
|
|
/// Mutations go through the store's intent API only.
|
|
@MainActor
|
|
var privateChats: [PeerID: [BitchatMessage]] {
|
|
conversationStore?.directMessagesByRoutingPeerID() ?? [:]
|
|
}
|
|
|
|
/// Unread chats, derived from the store's unread state.
|
|
@MainActor
|
|
var unreadMessages: Set<PeerID> {
|
|
conversationStore?.unreadDirectRoutingPeerIDs() ?? []
|
|
}
|
|
|
|
@MainActor
|
|
private func messages(for peerID: PeerID) -> [BitchatMessage] {
|
|
conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
|
}
|
|
|
|
// MARK: - Message Consolidation
|
|
|
|
/// Consolidates messages from different peer ID representations into a single chat.
|
|
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
|
/// - Parameters:
|
|
/// - peerID: The target peer ID to consolidate messages into
|
|
/// - peerNickname: The peer's display name (lowercased for matching)
|
|
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
|
|
/// - Returns: True if any unread messages were found during consolidation
|
|
@MainActor
|
|
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
|
guard let meshService = meshService, let store = conversationStore else { return false }
|
|
var hasUnreadMessages = false
|
|
|
|
// 1. Consolidate from stable Noise key (64-char hex)
|
|
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
|
let nostrMessages = messages(for: noiseKeyHex)
|
|
|
|
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
|
|
for message in nostrMessages {
|
|
// Update senderPeerID for correct read receipts
|
|
let updatedMessage = BitchatMessage(
|
|
id: message.id,
|
|
sender: message.sender,
|
|
content: message.content,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: message.deliveryStatus
|
|
)
|
|
// Store append dedups by message ID (skips ones the
|
|
// target chat already has).
|
|
guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue }
|
|
|
|
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
|
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
|
if message.senderPeerID != meshService.myPeerID {
|
|
let messageAge = Date().timeIntervalSince(message.timestamp)
|
|
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
|
hasUnreadMessages = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasUnreadMessages {
|
|
store.markUnread(.directPeer(peerID))
|
|
} else {
|
|
store.markRead(.directPeer(noiseKeyHex))
|
|
}
|
|
|
|
store.removeConversation(.directPeer(noiseKeyHex))
|
|
}
|
|
}
|
|
|
|
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
|
let normalizedNickname = peerNickname.lowercased()
|
|
var tempPeerIDsToConsolidate: [PeerID] = []
|
|
|
|
for (storedPeerID, messages) in privateChats {
|
|
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
|
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
|
if nicknamesMatch && !messages.isEmpty {
|
|
tempPeerIDsToConsolidate.append(storedPeerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !tempPeerIDsToConsolidate.isEmpty {
|
|
var consolidatedCount = 0
|
|
var hadUnreadTemp = false
|
|
let unreadPeerIDs = unreadMessages
|
|
|
|
for tempPeerID in tempPeerIDsToConsolidate {
|
|
if unreadPeerIDs.contains(tempPeerID) {
|
|
hadUnreadTemp = true
|
|
}
|
|
|
|
for message in messages(for: tempPeerID) {
|
|
let updatedMessage = BitchatMessage(
|
|
id: message.id,
|
|
sender: message.sender,
|
|
content: message.content,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: peerID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: message.deliveryStatus
|
|
)
|
|
if store.append(updatedMessage, to: .directPeer(peerID)) {
|
|
consolidatedCount += 1
|
|
}
|
|
}
|
|
store.removeConversation(.directPeer(tempPeerID))
|
|
}
|
|
|
|
if hadUnreadTemp {
|
|
store.markUnread(.directPeer(peerID))
|
|
hasUnreadMessages = true
|
|
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
|
}
|
|
|
|
if consolidatedCount > 0 {
|
|
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
|
}
|
|
}
|
|
|
|
return hasUnreadMessages
|
|
}
|
|
|
|
/// Syncs the read receipt tracking between manager and view model for sent messages
|
|
@MainActor
|
|
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
|
for message in messages(for: peerID) {
|
|
if message.sender == nickname {
|
|
if let status = message.deliveryStatus {
|
|
switch status {
|
|
case .read, .delivered:
|
|
externalReceipts.insert(message.id)
|
|
sentReadReceipts.insert(message.id)
|
|
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start a private chat with a peer. Selection is mutated through the
|
|
/// store's intent (the store owns it); the manager keeps its side
|
|
/// effects (read receipts, unread clearing).
|
|
@MainActor
|
|
func startChat(with peerID: PeerID) {
|
|
// Also creates the conversation if needed and updates the derived
|
|
// `selectedConversationID`; `selectedPeer` mirrors the change.
|
|
conversationStore?.setSelectedPrivatePeer(peerID)
|
|
|
|
// Mark messages as read
|
|
markAsRead(from: peerID)
|
|
}
|
|
|
|
/// End the current private chat (selection returns to the active public
|
|
/// channel's conversation).
|
|
func endChat() {
|
|
conversationStore?.setSelectedPrivatePeer(nil)
|
|
}
|
|
|
|
/// Mark messages from a peer as read
|
|
@MainActor
|
|
func markAsRead(from peerID: PeerID) {
|
|
conversationStore?.markRead(.directPeer(peerID))
|
|
|
|
// Send read receipts for unread messages that haven't been sent yet
|
|
for message in messages(for: peerID) {
|
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
|
sendReadReceipt(for: message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func sendReadReceipt(for message: BitchatMessage) {
|
|
guard !sentReadReceipts.contains(message.id),
|
|
let senderPeerID = message.senderPeerID else {
|
|
return
|
|
}
|
|
|
|
// Create read receipt using the simplified method
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
|
readerNickname: meshService?.myNickname ?? ""
|
|
)
|
|
|
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
|
if let router = messageRouter {
|
|
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
|
let messageID = message.id
|
|
// Claim the receipt synchronously so a second read scan in the
|
|
// same runloop pass (chat open triggers two) can't route a
|
|
// duplicate; release the claim on a failed route (no reachable
|
|
// transport) so a later read scan retries instead of permanently
|
|
// losing the receipt.
|
|
sentReadReceipts.insert(messageID)
|
|
Task { @MainActor [weak self] in
|
|
if !router.sendReadReceipt(receipt, to: senderPeerID) {
|
|
self?.sentReadReceipts.remove(messageID)
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: preserve previous behavior (best-effort mesh send).
|
|
sentReadReceipts.insert(message.id)
|
|
meshService?.sendReadReceipt(receipt, to: senderPeerID)
|
|
}
|
|
}
|
|
}
|