mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 07:45:20 +00:00
971 lines
42 KiB
Swift
971 lines
42 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// The narrow surface `ChatPrivateConversationCoordinator` needs from its owner.
|
|
///
|
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
|
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
|
/// testable (see `ChatPrivateConversationCoordinatorContextTests`) and makes
|
|
/// its true dependencies explicit. The surface is intentionally large — it
|
|
/// documents the coordinator's real coupling to private-chat state, peer
|
|
/// identity, and the routing/ack transports.
|
|
@MainActor
|
|
protocol ChatPrivateConversationContext: AnyObject {
|
|
// MARK: Conversation state
|
|
var privateChats: [PeerID: [BitchatMessage]] { get }
|
|
/// A single private chat's timeline. Witnessed by the store-direct
|
|
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
|
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
|
|
var sentReadReceipts: Set<String> { 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)
|
|
/// 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
|
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
|
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
|
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
|
@discardableResult
|
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool
|
|
/// Moves the open private chat to `newPeerID` when the current selection is
|
|
/// one of the peer IDs being migrated away.
|
|
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID)
|
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
|
func notifyUIChanged()
|
|
|
|
// MARK: Peers & identity
|
|
var myPeerID: PeerID { get }
|
|
func peerNickname(for peerID: PeerID) -> String?
|
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
|
func isPeerReachable(_ peerID: PeerID) -> Bool
|
|
func isPeerBlocked(_ peerID: PeerID) -> Bool
|
|
func noisePublicKey(for peerID: PeerID) -> Data?
|
|
/// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected.
|
|
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID?
|
|
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
|
func getFingerprint(for peerID: PeerID) -> String?
|
|
func storedFingerprint(for peerID: PeerID) -> String?
|
|
func clearStoredFingerprint(for peerID: PeerID)
|
|
|
|
// MARK: Nostr identity
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
|
func currentNostrIdentity() -> NostrIdentity?
|
|
|
|
// MARK: Routing & acknowledgements
|
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
|
@discardableResult
|
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
|
|
/// Confirms an authenticated delivery/read acknowledgement so the router
|
|
/// stops retaining the original private message for resend. The peer
|
|
/// aliases scope the removal to the authenticated sender.
|
|
@discardableResult
|
|
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool
|
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
|
@discardableResult
|
|
func sendGeohashPrivateMessage(
|
|
_ content: String,
|
|
toRecipientHex recipientHex: String,
|
|
from identity: NostrIdentity,
|
|
messageID: String
|
|
) -> Bool
|
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
|
|
|
// MARK: System messages
|
|
func addMeshOnlySystemMessage(_ content: String)
|
|
/// Appends a local-only system line into a specific private thread —
|
|
/// errors about a DM belong in that DM, not on the active timeline.
|
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
|
|
|
// MARK: Favorites & notifications
|
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
/// The persisted favorite relationship resolved from a short 16-hex mesh
|
|
/// peer ID (matched against the IDs derived from stored noise keys).
|
|
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
|
/// Posts the incoming-private-message local notification.
|
|
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
|
|
}
|
|
|
|
extension ChatViewModel: ChatPrivateConversationContext {
|
|
// `privateChats` and `notifyUIChanged()` are shared requirements with
|
|
// `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`,
|
|
// `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their
|
|
// backing state in `ChatViewModel`. The remaining state members are
|
|
// satisfied by existing `ChatViewModel` properties and methods.
|
|
|
|
var myPeerID: PeerID { meshService.myPeerID }
|
|
|
|
func peerNickname(for peerID: PeerID) -> String? {
|
|
meshService.peerNickname(peerID: peerID)
|
|
}
|
|
|
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
|
meshService.isPeerConnected(peerID)
|
|
}
|
|
|
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
|
meshService.isPeerReachable(peerID)
|
|
}
|
|
|
|
func noisePublicKey(for peerID: PeerID) -> Data? {
|
|
unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
|
|
}
|
|
|
|
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? {
|
|
unifiedPeerService.peers.first(where: { $0.noisePublicKey == noiseKey })?.peerID
|
|
}
|
|
|
|
func storedFingerprint(for peerID: PeerID) -> String? {
|
|
peerIDToPublicKeyFingerprint[peerID]
|
|
}
|
|
|
|
func clearStoredFingerprint(for peerID: PeerID) {
|
|
peerIdentityStore.setFingerprint(nil, for: peerID)
|
|
}
|
|
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
|
}
|
|
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
|
}
|
|
|
|
func currentNostrIdentity() -> NostrIdentity? {
|
|
try? idBridge.getCurrentNostrIdentity()
|
|
}
|
|
|
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
|
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
}
|
|
|
|
@discardableResult
|
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool {
|
|
messageRouter.sendReadReceipt(receipt, to: peerID)
|
|
}
|
|
|
|
@discardableResult
|
|
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool {
|
|
messageRouter.markDelivered(messageID, for: peerIDs)
|
|
}
|
|
|
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
|
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
}
|
|
|
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
|
meshService.sendReadReceipt(receipt, to: peerID)
|
|
}
|
|
|
|
@discardableResult
|
|
func sendGeohashPrivateMessage(
|
|
_ content: String,
|
|
toRecipientHex recipientHex: String,
|
|
from identity: NostrIdentity,
|
|
messageID: String
|
|
) -> Bool {
|
|
makeGeohashNostrTransport().sendPrivateMessageGeohash(
|
|
content: content,
|
|
toRecipientHex: recipientHex,
|
|
from: identity,
|
|
messageID: messageID
|
|
)
|
|
}
|
|
|
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
|
}
|
|
|
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
|
}
|
|
|
|
func addSystemMessage(_ content: String) {
|
|
addSystemMessage(content, timestamp: Date())
|
|
}
|
|
|
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
|
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
|
}
|
|
|
|
// `favoriteRelationship(forPeerID:)` is shared with
|
|
// `ChatPeerIdentityContext`; its witness lives in
|
|
// `ChatPeerIdentityCoordinator.swift`.
|
|
|
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
|
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
|
peerNoisePublicKey: noiseKey,
|
|
favorited: favorited,
|
|
peerNickname: nickname,
|
|
peerNostrPublicKey: nostrPublicKey
|
|
)
|
|
}
|
|
|
|
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
|
|
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
|
|
}
|
|
|
|
func makeGeohashNostrTransport(
|
|
dependencies: NostrTransport.Dependencies? = nil
|
|
) -> NostrTransport {
|
|
let transport = NostrTransport(
|
|
keychain: keychain,
|
|
idBridge: idBridge,
|
|
dependencies: dependencies
|
|
)
|
|
transport.senderPeerID = meshService.myPeerID
|
|
transport.eventDelegate = self
|
|
return transport
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatPrivateConversationCoordinator {
|
|
private unowned let context: any ChatPrivateConversationContext
|
|
|
|
// Outbox retries re-envelope the same message in fresh private events, so
|
|
// relay-level event-ID dedup can't catch them; track inbound GeoDM
|
|
// message IDs so each copy past the first costs one (already-deduped)
|
|
// ack check and nothing else.
|
|
private var seenInboundGeoDMIDs: Set<String> = []
|
|
private var seenInboundGeoDMOrder: [String] = []
|
|
private static let seenInboundGeoDMCap = 512
|
|
|
|
init(context: any ChatPrivateConversationContext) {
|
|
self.context = context
|
|
}
|
|
|
|
/// Returns `false` if this GeoDM message ID was already handled.
|
|
private func markInboundGeoDMSeen(_ messageId: String) -> Bool {
|
|
guard !seenInboundGeoDMIDs.contains(messageId) else { return false }
|
|
seenInboundGeoDMIDs.insert(messageId)
|
|
seenInboundGeoDMOrder.append(messageId)
|
|
if seenInboundGeoDMOrder.count > Self.seenInboundGeoDMCap {
|
|
seenInboundGeoDMIDs.remove(seenInboundGeoDMOrder.removeFirst())
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Account DMs can arrive under the authenticated peer's full Noise key
|
|
/// while an existing mesh conversation is keyed by its derived short ID.
|
|
/// These are the only aliases we may safely join: the short ID is derived
|
|
/// directly from the authenticated key, rather than guessed from a
|
|
/// nickname or found by scanning unrelated chats.
|
|
private func accountConversationAliases(for peerID: PeerID) -> [PeerID] {
|
|
guard peerID.noiseKey != nil else { return [peerID] }
|
|
let shortPeerID = peerID.toShort()
|
|
return shortPeerID == peerID ? [peerID] : [peerID, shortPeerID]
|
|
}
|
|
|
|
/// Keeps a connected account DM on its short routing ID and an offline DM
|
|
/// on its stable Noise-key ID, folding the other authenticated alias into
|
|
/// it and handing an open sheet across without closing it.
|
|
private func consolidateAccountConversationAliases(for peerID: PeerID) -> PeerID {
|
|
let aliases = accountConversationAliases(for: peerID)
|
|
guard aliases.count > 1 else { return peerID }
|
|
|
|
let shortPeerID = peerID.toShort()
|
|
let targetPeerID = context.isPeerConnected(shortPeerID) ? shortPeerID : peerID
|
|
let sourcePeerIDs = aliases.filter { $0 != targetPeerID }
|
|
|
|
for sourcePeerID in sourcePeerIDs where !context.privateMessages(for: sourcePeerID).isEmpty {
|
|
context.migratePrivateChat(from: sourcePeerID, to: targetPeerID)
|
|
// ConversationStore deliberately preserves message values during a
|
|
// generic migration, including its destination-wins rule for
|
|
// duplicate IDs. Rewrite the resulting canonical copies so later
|
|
// read-receipt scans compare against the new routing key without
|
|
// replacing a newer destination value with the source snapshot.
|
|
let canonicalMessages = context.privateMessages(for: targetPeerID)
|
|
for message in canonicalMessages where message.senderPeerID == sourcePeerID {
|
|
context.upsertPrivateMessage(
|
|
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: targetPeerID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: message.deliveryStatus,
|
|
isBridged: message.isBridged
|
|
),
|
|
in: targetPeerID
|
|
)
|
|
}
|
|
}
|
|
context.handOffSelectedPrivateChat(from: sourcePeerIDs, to: targetPeerID)
|
|
return targetPeerID
|
|
}
|
|
|
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
|
guard !content.isEmpty else { return }
|
|
|
|
if context.isPeerBlocked(peerID) {
|
|
let nickname = context.peerNickname(for: peerID) ?? "anon"
|
|
context.addLocalPrivateSystemMessage(
|
|
String(
|
|
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked person"),
|
|
locale: .current,
|
|
nickname
|
|
),
|
|
to: peerID
|
|
)
|
|
return
|
|
}
|
|
|
|
if peerID.isGeoDM {
|
|
sendGeohashDM(content, to: peerID)
|
|
return
|
|
}
|
|
|
|
// Resolve the favorite behind this conversation. It may be keyed by
|
|
// the full 64-hex noise-key ID (offline favorite row) or the short
|
|
// 16-hex mesh ID — the raw hex bytes of a short ID are a routing ID,
|
|
// never a noise key, so they must not be used as a favorites key.
|
|
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
|
let isConnected = context.isPeerConnected(peerID)
|
|
let isReachable = context.isPeerReachable(peerID)
|
|
let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
|
|
?? context.favoriteRelationship(forPeerID: peerID)
|
|
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
|
|
|
// "anon" matches the app's default-nickname convention; "user" is
|
|
// banned copy.
|
|
let recipientNickname = context.peerNickname(for: peerID)
|
|
?? favoriteStatus?.peerNickname
|
|
?? "anon"
|
|
|
|
let messageID = UUID().uuidString
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: context.nickname,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: recipientNickname,
|
|
senderPeerID: context.myPeerID,
|
|
mentions: nil,
|
|
deliveryStatus: .sending
|
|
)
|
|
|
|
context.appendPrivateMessage(message, to: peerID)
|
|
context.notifyUIChanged()
|
|
|
|
// Always hand the message to the router — it owns delivery. A live
|
|
// link sends now; an unreachable peer gets the retained-outbox path
|
|
// (resend on reconnect, courier deposits, bridge drops). Pre-judging
|
|
// reachability here used to mark the message failed without ever
|
|
// routing it, silently bypassing all of that (field-found: DMs
|
|
// composed after a peer's reachability window lapsed were dead on
|
|
// arrival while identical DMs sent a minute earlier delivered).
|
|
context.routePrivateMessage(
|
|
content,
|
|
to: peerID,
|
|
recipientNickname: recipientNickname,
|
|
messageID: messageID
|
|
)
|
|
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
|
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
|
}
|
|
// Otherwise the message stays "sending"; router callbacks move it to
|
|
// carried (📦) when a courier/bridge copy ships, delivered/read on
|
|
// acks, or failed when the outbox TTL expires.
|
|
}
|
|
|
|
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
|
guard case .location(let channel) = context.activeChannel else {
|
|
// The failure happened inside a geoDM thread — surface it there,
|
|
// not on the public timeline (matches the sibling blocked/unknown
|
|
// errors routed into the thread by #1415).
|
|
context.addLocalPrivateSystemMessage(
|
|
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel"),
|
|
to: peerID
|
|
)
|
|
return
|
|
}
|
|
|
|
let messageID = UUID().uuidString
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: context.nickname,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: context.nickname,
|
|
senderPeerID: context.myPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
|
|
context.appendPrivateMessage(message, to: peerID)
|
|
context.notifyUIChanged()
|
|
|
|
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
|
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) {
|
|
context.setPrivateDeliveryStatus(
|
|
.failed(
|
|
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
|
),
|
|
forMessageID: messageID,
|
|
peerID: peerID
|
|
)
|
|
context.addLocalPrivateSystemMessage(
|
|
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because the person is blocked"),
|
|
to: peerID
|
|
)
|
|
return
|
|
}
|
|
|
|
do {
|
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
|
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
|
context.setPrivateDeliveryStatus(
|
|
.failed(
|
|
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
|
),
|
|
forMessageID: messageID,
|
|
peerID: peerID
|
|
)
|
|
return
|
|
}
|
|
|
|
SecureLogger.debug(
|
|
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
|
|
category: .session
|
|
)
|
|
let accepted = context.sendGeohashPrivateMessage(
|
|
content,
|
|
toRecipientHex: recipientHex,
|
|
from: identity,
|
|
messageID: messageID
|
|
)
|
|
let status: DeliveryStatus = accepted
|
|
? .sent
|
|
: .failed(
|
|
reason: String(
|
|
localized: "content.delivery.reason.not_delivered",
|
|
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
|
|
)
|
|
)
|
|
context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID)
|
|
} catch {
|
|
context.setPrivateDeliveryStatus(
|
|
.failed(
|
|
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
|
),
|
|
forMessageID: messageID,
|
|
peerID: peerID
|
|
)
|
|
}
|
|
}
|
|
|
|
func handlePrivateMessage(
|
|
_ payload: NoisePayload,
|
|
senderPubkey: String,
|
|
convKey: PeerID,
|
|
id: NostrIdentity,
|
|
messageTimestamp: Date
|
|
) {
|
|
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
|
let messageId = pm.messageID
|
|
|
|
// Ack before the dedup guard: a re-sent copy means the sender may not
|
|
// have our DELIVERED yet, and markGeoDeliveryAckSent dedups the
|
|
// actual sends.
|
|
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
|
|
|
guard markInboundGeoDMSeen(messageId) else { return }
|
|
|
|
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
|
|
|
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
|
return
|
|
}
|
|
|
|
// Prefer the favorite's stored nickname when the sender resolved to a
|
|
// known noise key; the Nostr display name is a geohash-scoped
|
|
// fallback (e.g. "anon#678e") that would mislabel favorite-transport
|
|
// DMs. Geohash conversations (nostr_ keys) keep the geo name.
|
|
let senderName: String = {
|
|
if let noiseKey = convKey.noiseKey,
|
|
let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
|
|
!favoriteNickname.isEmpty {
|
|
return favoriteNickname
|
|
}
|
|
return context.displayNameForNostrPubkey(senderPubkey)
|
|
}()
|
|
|
|
// Favorite notifications ride the PM channel over Nostr too; intercept
|
|
// them so they update the relationship instead of rendering as text.
|
|
if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") {
|
|
handleFavoriteNotification(
|
|
pm.content,
|
|
from: convKey,
|
|
senderNickname: senderName
|
|
)
|
|
return
|
|
}
|
|
|
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
|
|
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
|
|
|
let message = BitchatMessage(
|
|
id: messageId,
|
|
sender: senderName,
|
|
content: pm.content,
|
|
timestamp: messageTimestamp,
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: context.nickname,
|
|
senderPeerID: conversationPeerID,
|
|
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
|
)
|
|
|
|
context.appendPrivateMessage(message, to: conversationPeerID)
|
|
|
|
let isViewing = context.selectedPrivateChatPeer == conversationPeerID
|
|
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
|
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
|
if shouldMarkUnread {
|
|
context.markPrivateChatUnread(conversationPeerID)
|
|
}
|
|
|
|
if isViewing {
|
|
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
|
}
|
|
|
|
if !isViewing && shouldMarkUnread {
|
|
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID)
|
|
}
|
|
|
|
context.notifyUIChanged()
|
|
}
|
|
|
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
|
|
|
let aliases = accountConversationAliases(for: convKey)
|
|
let clearedRetainedMessage = convKey.noiseKey != nil
|
|
? context.markMessageDelivered(messageID, for: aliases)
|
|
: false
|
|
let hasConversationMessage = aliases.contains {
|
|
context.privateChat($0, containsMessageWithID: messageID)
|
|
}
|
|
if hasConversationMessage {
|
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
|
let didChange = context.setPrivateDeliveryStatus(
|
|
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
|
forMessageID: messageID,
|
|
peerID: conversationPeerID
|
|
)
|
|
if didChange {
|
|
context.notifyUIChanged()
|
|
}
|
|
SecureLogger.info(
|
|
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
|
category: .session
|
|
)
|
|
} else if clearedRetainedMessage {
|
|
SecureLogger.debug(
|
|
"GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
|
category: .session
|
|
)
|
|
} else {
|
|
// A stale ack for a message this device no longer tracks (dropped
|
|
// outbox entry, cleared chat, or a peer re-acking after losing our
|
|
// receipt) — expected occasionally, not actionable.
|
|
SecureLogger.debug("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
|
}
|
|
}
|
|
|
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
|
|
|
let aliases = accountConversationAliases(for: convKey)
|
|
let clearedRetainedMessage = convKey.noiseKey != nil
|
|
? context.markMessageDelivered(messageID, for: aliases)
|
|
: false
|
|
let hasConversationMessage = aliases.contains {
|
|
context.privateChat($0, containsMessageWithID: messageID)
|
|
}
|
|
if hasConversationMessage {
|
|
let conversationPeerID = consolidateAccountConversationAliases(for: convKey)
|
|
let didChange = context.setPrivateDeliveryStatus(
|
|
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
|
forMessageID: messageID,
|
|
peerID: conversationPeerID
|
|
)
|
|
if didChange {
|
|
context.notifyUIChanged()
|
|
}
|
|
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
|
} else if clearedRetainedMessage {
|
|
SecureLogger.debug(
|
|
"GeoDM: recv READ for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
|
category: .session
|
|
)
|
|
} else {
|
|
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
|
}
|
|
}
|
|
|
|
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
|
guard context.markGeoDeliveryAckSent(messageId) else { return }
|
|
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
|
}
|
|
|
|
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
|
guard context.markReadReceiptSent(messageId) else { return }
|
|
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
|
}
|
|
|
|
func handlePrivateMessage(_ message: BitchatMessage) {
|
|
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
|
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
|
|
|
guard let peerID = senderPeerID else {
|
|
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
|
|
return
|
|
}
|
|
|
|
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
|
handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
|
|
return
|
|
}
|
|
|
|
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
|
|
|
|
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
|
|
let stableKeyHex = PeerID(hexData: peerNoiseKey)
|
|
let nostrMessages = context.privateMessages(for: stableKeyHex)
|
|
if stableKeyHex != peerID,
|
|
!nostrMessages.isEmpty {
|
|
// 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)",
|
|
category: .session
|
|
)
|
|
}
|
|
}
|
|
|
|
if isDuplicateMessage(message.id, targetPeerID: peerID) {
|
|
return
|
|
}
|
|
|
|
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)
|
|
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
|
mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
|
|
|
|
let isViewing = context.selectedPrivateChatPeer == peerID
|
|
if isViewing {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: context.myPeerID,
|
|
readerNickname: context.nickname
|
|
)
|
|
context.sendMeshReadReceipt(receipt, to: peerID)
|
|
context.markReadReceiptSent(message.id)
|
|
} else {
|
|
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 {
|
|
context.privateChatsContainMessage(withID: messageId)
|
|
}
|
|
|
|
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
|
|
// 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?) {
|
|
guard let key,
|
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
|
ephemeralPeerID != targetPeerID
|
|
else {
|
|
return
|
|
}
|
|
|
|
context.upsertPrivateMessage(message, in: ephemeralPeerID)
|
|
}
|
|
|
|
func handleViewingThisChat(
|
|
_ message: BitchatMessage,
|
|
targetPeerID: PeerID,
|
|
key: Data?,
|
|
senderPubkey: String
|
|
) {
|
|
context.markPrivateChatRead(targetPeerID)
|
|
if let key,
|
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
|
|
context.markPrivateChatRead(ephemeralPeerID)
|
|
}
|
|
guard !context.sentReadReceipts.contains(message.id) else { return }
|
|
|
|
if let key {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: context.myPeerID,
|
|
readerNickname: context.nickname
|
|
)
|
|
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
|
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
|
context.markReadReceiptSent(message.id)
|
|
} else if let identity = context.currentNostrIdentity() {
|
|
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
|
context.markReadReceiptSent(message.id)
|
|
SecureLogger.debug(
|
|
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
|
category: .session
|
|
)
|
|
}
|
|
}
|
|
|
|
func markAsUnreadIfNeeded(
|
|
shouldMarkAsUnread: Bool,
|
|
targetPeerID: PeerID,
|
|
key: Data?,
|
|
isRecentMessage: Bool,
|
|
senderNickname: String,
|
|
messageContent: String
|
|
) {
|
|
guard shouldMarkAsUnread else { return }
|
|
|
|
context.markPrivateChatUnread(targetPeerID)
|
|
if let key,
|
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
|
ephemeralPeerID != targetPeerID {
|
|
context.markPrivateChatUnread(ephemeralPeerID)
|
|
}
|
|
if isRecentMessage {
|
|
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
|
|
}
|
|
}
|
|
|
|
/// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either
|
|
/// transport. `peerID` must resolve to a noise key — a full 64-hex ID or
|
|
/// one the unified peer list knows; otherwise the notification is dropped.
|
|
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
|
let isFavorite = content.hasPrefix("[FAVORITED]")
|
|
let parts = content.split(separator: ":")
|
|
|
|
var nostrPubkey: String?
|
|
if parts.count > 1 {
|
|
nostrPubkey = String(parts[1])
|
|
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
|
|
}
|
|
|
|
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
|
guard let finalNoiseKey = noiseKey else {
|
|
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
|
|
return
|
|
}
|
|
|
|
let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false
|
|
context.updatePeerFavoritedUs(
|
|
noiseKey: finalNoiseKey,
|
|
favorited: isFavorite,
|
|
nickname: senderNickname,
|
|
nostrPublicKey: nostrPubkey
|
|
)
|
|
|
|
if isFavorite && nostrPubkey != nil {
|
|
SecureLogger.info(
|
|
"💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...",
|
|
category: .session
|
|
)
|
|
}
|
|
|
|
if prior != isFavorite {
|
|
let action = isFavorite ? "favorited" : "unfavorited"
|
|
context.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
|
|
}
|
|
}
|
|
|
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
|
let isActionMessage = message.content.hasPrefix("* ")
|
|
&& message.content.hasSuffix(" *")
|
|
&& (message.content.contains("🫂")
|
|
|| message.content.contains("🐟")
|
|
|| message.content.contains("took a screenshot"))
|
|
|
|
guard isActionMessage else { return message }
|
|
|
|
return BitchatMessage(
|
|
id: message.id,
|
|
sender: "system",
|
|
content: String(message.content.dropFirst(2).dropLast(2)),
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: message.senderPeerID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: message.deliveryStatus
|
|
)
|
|
}
|
|
|
|
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
|
|
let currentFingerprint = context.getFingerprint(for: peerID)
|
|
|
|
if context.privateMessages(for: peerID).isEmpty {
|
|
// 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 {
|
|
let oldFingerprint = context.storedFingerprint(for: oldPeerID)
|
|
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
|
|
guard !recentMessages.isEmpty else { continue }
|
|
|
|
if let currentFp = currentFingerprint,
|
|
let oldFp = oldFingerprint,
|
|
currentFp == oldFp {
|
|
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
|
|
)
|
|
}
|
|
|
|
SecureLogger.info(
|
|
"📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)",
|
|
category: .session
|
|
)
|
|
} else if currentFingerprint == nil || oldFingerprint == nil {
|
|
let isRelevantChat = recentMessages.contains { msg in
|
|
(msg.sender == senderNickname && msg.sender != context.nickname)
|
|
|| (msg.sender == context.nickname && msg.recipientNickname == senderNickname)
|
|
}
|
|
|
|
if isRelevantChat {
|
|
didMigrate = true
|
|
if recentMessages.count == messages.count {
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
} else {
|
|
partiallyMigratedMessages.append(contentsOf: recentMessages)
|
|
}
|
|
|
|
SecureLogger.warning(
|
|
"📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)",
|
|
category: .session
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !oldPeerIDsToRemove.isEmpty {
|
|
for oldID in oldPeerIDsToRemove {
|
|
// 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)
|
|
}
|
|
|
|
for message in partiallyMigratedMessages {
|
|
context.appendPrivateMessage(message, to: peerID)
|
|
}
|
|
|
|
if didMigrate {
|
|
context.notifyUIChanged()
|
|
}
|
|
}
|
|
}
|
|
|
|
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
|
|
if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) {
|
|
if context.isPeerBlocked(peerID) { return true }
|
|
if peerID.isGeoChat || peerID.isGeoDM,
|
|
let full = context.nostrKeyMapping[peerID]?.lowercased(),
|
|
context.isNostrBlocked(pubkeyHexLowercased: full) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Default for conforming test contexts that model chats as a dictionary;
|
|
/// `ChatViewModel` overrides with a store-direct lookup.
|
|
extension ChatPrivateConversationContext {
|
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
|
privateChats[peerID] ?? []
|
|
}
|
|
}
|