mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers When a private message has no reachable transport, the router now seals it to the recipient's Noise static key (new one-way Noise X pattern) and hands the envelope to up to three connected mutual favorites. Couriers store the opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB, 24 h) and hand it over when the recipient's announce matches a rotating HMAC recipient tag; the recipient opens it and the message flows through the normal private-message pipeline, so dedup and delivery acks just work. - CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation - Noise X one-way pattern reusing the existing handshake machinery, domain-separated by a courier prologue; sender identity authenticated via the ss DH (no forward secrecy - documented tradeoff) - CourierStore with eviction, file persistence, and panic-wipe integration - Rotating recipient tags (HMAC over epoch day) so carried envelopes don't correlate for observers who don't already know the recipient's key - New "carried" delivery status with figure.walk glyph; header indicator while carrying mail for others - Three-node end-to-end test ferrying packets through real BLEService instances, plus codec/crypto/store/router suites (986 tests green) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix courier handoff verification and directed sends * Authenticate courier deposits by ingress peer * Gate courier handover on direct announces and isolate store test Envelopes are removed from the courier store optimistically, so releasing them on a relayed (multi-hop) announce risks losing carried mail to a speculative flood that never reaches the recipient. Handover now also requires the announce to have arrived directly (full TTL), i.e. an actual encounter with a live link; regression test builds a relayed copy of a genuinely signed announce (TTL is excluded from announce signatures). Also make CourierStore's on-disk location injectable so the persistence test round-trips through a temp directory instead of wiping the real Application Support store, and reattach BLEAnnounceHandler's doc comment to the class it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use Xcode-bundled Swift in CI instead of a standalone toolchain The unpinned setup-swift action installs Swift 6.1, which refuses the SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not supported by the compiler"). Jobs passed or failed depending on which image they landed on. The Xcode-bundled toolchain always matches the image's SDK, and matches local development. Cache keys now include the toolchain version so artifacts from one compiler are never restored into builds with another. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop couriered mail from blocked senders at envelope open The UI-layer block check (isPeerBlocked in the transport event coordinator) resolves a fingerprint from the live session or peer list, but a couriered message arrives precisely when its sender is absent — no session, no registry entry — so the check failed open and a blocked identity's mail was delivered anyway. Gate in openCourierEnvelope, where the sealed sender's full static key is in hand. End-to-end test ferries a full deposit→carry→handover round and verifies the envelope from a blocked sender never reaches the delegate (confirmed failing without the gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption - UnifiedPeerService: dedup offline favorites against mesh peers by noise key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs (never equal), leaving only a nickname+isConnected heuristic — a mutual favorite that was reachable-but-not-connected or renamed rendered twice, and a same-nick stranger could suppress a favorite entirely. - Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live PM handler so they update theyFavoritedUs instead of rendering as chat text; mutual favorites can now form over Nostr. Delete the dead favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits). - NostrTransport.isPeerReachable: match short form regardless of incoming ID width — toggling an offline favorite (addressed by 64-hex noise key) was silently dropped with no reachable transport. - BLEService.sendPrivateMessage: normalize recipient to the short ID like sendFilePrivate, so a 64-hex target hits the existing Noise session instead of initiating a handshake with a 32-byte wire recipient ID. - /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte routing ID for mesh peers — into the favorites store as a "noise key", and stop double-sending the favorite notification; delegate to toggleFavorite with a proper state check. - FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored nickname when the caller passes the "Unknown" placeholder. - Bump marketing version to 1.5.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline Field-tested on device: with a DM window opened while the peer was on mesh (conversation keyed by the short 16-hex ID), walking out of range and sending failed instantly with "peer not reachable" even though the header showed the peer as Nostr-reachable (mutual favorite, npub known). sendPrivateMessage derived the favorites key as Data(hexString: peerID.id) — for a short mesh ID that is the 8-byte routing ID, never the noise key — so the mutual-favorite/Nostr-key checks always came up empty and the send failed before reaching MessageRouter. Conversations keyed by the full 64-hex noise-key ID (opened from the offline favorite row) were unaffected, which is why later tests appeared to work. Resolve the noise key properly (peerID.noiseKey, then the unified peer row, then the favorites store by derived short ID) and add a regression test for the mesh-keyed-peer-goes-offline case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Label Nostr DMs from favorites with their stored nickname Field-tested: a DM delivered over the Nostr fallback rendered as "anon#678e" instead of the sender's name. The inbound handler named the sender via displayNameForNostrPubkey, which only knows geohash-scoped names — even though the pipeline had already resolved the sender's noise key (the conversation is keyed by it). When the conversation key carries a noise key, prefer the favorite's stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This also stops an inbound Nostr [FAVORITED] from overwriting the stored nickname with the anon fallback, since the same name feeds updatePeerFavoritedUs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix courier path for offline favorites addressed by noise-key IDs Two Codex review findings, both the same ID-width confusion this PR targets, in the courier flow: - CourierDirectory.favoritesBacked resolved recipients only via getFavoriteStatus(forPeerID:), which requires a short 16-hex ID — offline favorites are addressed by the full 64-hex noise-key ID, so attemptCourierDeposit silently bailed for exactly the peers couriers exist to serve. The 64-hex ID now yields its own key directly. - openCourierEnvelope emitted the derived short mesh ID even when the sender has no live mesh identity, landing couriered mail in an unresolvable short-ID thread labeled "Unknown". Absent senders now emit the full noise-key ID so the message joins the stable favorite conversation; present senders keep the live short-ID thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
714 lines
31 KiB
Swift
714 lines
31 KiB
Swift
//
|
|
// ChatPrivateConversationCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatPrivateConversationCoordinator` against a mock
|
|
// `ChatPrivateConversationContext` — proving the coordinator works without a
|
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` exemplar.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatPrivateConversationContext` proving that
|
|
/// `ChatPrivateConversationCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatPrivateConversationContext: ChatPrivateConversationContext {
|
|
// Conversation state
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
var sentReadReceipts: Set<String> = []
|
|
var sentGeoDeliveryAcks: Set<String> = []
|
|
var unreadPrivateMessages: Set<PeerID> = []
|
|
var selectedPrivateChatPeer: PeerID?
|
|
var nickname = "me"
|
|
var activeChannel: ChannelID = .mesh
|
|
var nostrKeyMapping: [PeerID: String] = [:]
|
|
private(set) var notifyUIChangedCount = 0
|
|
|
|
@discardableResult
|
|
func markReadReceiptSent(_ messageID: String) -> Bool {
|
|
sentReadReceipts.insert(messageID).inserted
|
|
}
|
|
|
|
@discardableResult
|
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
|
|
sentGeoDeliveryAcks.insert(messageID).inserted
|
|
}
|
|
|
|
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
|
|
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
|
|
selectedPrivateChatPeer = newPeerID
|
|
}
|
|
|
|
func notifyUIChanged() {
|
|
notifyUIChangedCount += 1
|
|
}
|
|
|
|
// Conversation store intents (mirror `ConversationStore` semantics:
|
|
// ordered insert, dedup by ID, no-downgrade status, unread carry on
|
|
// migrate) while recording calls for assertions.
|
|
private(set) var upsertedMessages: [(messageID: String, peerID: PeerID)] = []
|
|
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
|
|
|
|
@discardableResult
|
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
|
var chat = privateChats[peerID] ?? []
|
|
guard !chat.contains(where: { $0.id == message.id }) else {
|
|
privateChats[peerID] = chat
|
|
return false
|
|
}
|
|
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
|
|
chat.insert(message, at: index)
|
|
privateChats[peerID] = chat
|
|
return true
|
|
}
|
|
|
|
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
|
upsertedMessages.append((message.id, peerID))
|
|
if var chat = privateChats[peerID],
|
|
let index = chat.firstIndex(where: { $0.id == message.id }) {
|
|
chat[index] = message
|
|
privateChats[peerID] = chat
|
|
} else {
|
|
appendPrivateMessage(message, to: peerID)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
|
guard var chat = privateChats[peerID],
|
|
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
|
return false
|
|
}
|
|
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
|
|
return false
|
|
}
|
|
chat[index].deliveryStatus = status
|
|
privateChats[peerID] = chat
|
|
return true
|
|
}
|
|
|
|
func markPrivateChatUnread(_ peerID: PeerID) {
|
|
unreadPrivateMessages.insert(peerID)
|
|
}
|
|
|
|
func markPrivateChatRead(_ peerID: PeerID) {
|
|
unreadPrivateMessages.remove(peerID)
|
|
}
|
|
|
|
func removePrivateChat(_ peerID: PeerID) {
|
|
privateChats.removeValue(forKey: peerID)
|
|
unreadPrivateMessages.remove(peerID)
|
|
}
|
|
|
|
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
|
migratedChats.append((oldPeerID, newPeerID))
|
|
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
|
|
for message in source {
|
|
appendPrivateMessage(message, to: newPeerID)
|
|
}
|
|
if privateChats[newPeerID] == nil {
|
|
privateChats[newPeerID] = []
|
|
}
|
|
let wasUnread = unreadPrivateMessages.contains(oldPeerID)
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
if wasUnread {
|
|
unreadPrivateMessages.insert(newPeerID)
|
|
}
|
|
}
|
|
|
|
func privateChatsContainMessage(withID messageID: String) -> Bool {
|
|
privateChats.values.contains { chat in
|
|
chat.contains { $0.id == messageID }
|
|
}
|
|
}
|
|
|
|
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool {
|
|
privateChats[peerID]?.contains { $0.id == messageID } == true
|
|
}
|
|
|
|
// Peers & identity
|
|
var myPeerID = PeerID(str: "0011223344556677")
|
|
var nicknamesByPeerID: [PeerID: String] = [:]
|
|
var connectedPeers: Set<PeerID> = []
|
|
var reachablePeers: Set<PeerID> = []
|
|
var blockedPeers: Set<PeerID> = []
|
|
var noiseKeysByPeerID: [PeerID: Data] = [:]
|
|
var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:]
|
|
var peerIDsByNickname: [String: PeerID] = [:]
|
|
var fingerprintsByPeerID: [PeerID: String] = [:]
|
|
private(set) var clearedFingerprints: [PeerID] = []
|
|
|
|
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
|
|
func isPeerConnected(_ peerID: PeerID) -> Bool { connectedPeers.contains(peerID) }
|
|
func isPeerReachable(_ peerID: PeerID) -> Bool { reachablePeers.contains(peerID) }
|
|
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
|
func noisePublicKey(for peerID: PeerID) -> Data? { noiseKeysByPeerID[peerID] }
|
|
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] }
|
|
func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIDsByNickname[nickname] }
|
|
func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
|
|
func storedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
|
|
|
|
func clearStoredFingerprint(for peerID: PeerID) {
|
|
fingerprintsByPeerID.removeValue(forKey: peerID)
|
|
clearedFingerprints.append(peerID)
|
|
}
|
|
|
|
// Nostr identity
|
|
var blockedNostrPubkeys: Set<String> = []
|
|
var displayNamesByPubkey: [String: String] = [:]
|
|
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
blockedNostrPubkeys.contains(pubkeyHexLowercased)
|
|
}
|
|
|
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
|
displayNamesByPubkey[pubkeyHex] ?? "anon"
|
|
}
|
|
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
|
|
func currentNostrIdentity() -> NostrIdentity? { Self.dummyIdentity }
|
|
|
|
// Routing & acknowledgements
|
|
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, messageID: String)] = []
|
|
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
|
|
private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
|
private(set) var meshReadReceipts: [(messageID: String, peerID: PeerID)] = []
|
|
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
|
|
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
|
|
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
|
private(set) var embeddedDeliveryAckMessageIDs: [String] = []
|
|
|
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
|
routedPrivateMessages.append((content, peerID, messageID))
|
|
}
|
|
|
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
|
routedReadReceipts.append((receipt.originalMessageID, peerID))
|
|
}
|
|
|
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
|
routedFavoriteNotifications.append((peerID, isFavorite))
|
|
}
|
|
|
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
|
meshReadReceipts.append((receipt.originalMessageID, peerID))
|
|
}
|
|
|
|
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
geoPrivateMessages.append((content, recipientHex, messageID))
|
|
}
|
|
|
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
geoDeliveryAcks.append((messageID, recipientHex))
|
|
}
|
|
|
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
geoReadReceipts.append((messageID, recipientHex))
|
|
}
|
|
|
|
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) {
|
|
embeddedDeliveryAckMessageIDs.append(message.id)
|
|
}
|
|
|
|
// Favorites & notifications
|
|
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
|
|
private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = []
|
|
private(set) var privateMessageNotifications: [(senderName: String, message: String, peerID: PeerID)] = []
|
|
|
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
|
favoriteRelationshipsByNoiseKey[noiseKey]
|
|
}
|
|
|
|
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
|
|
favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value
|
|
}
|
|
|
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
|
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
|
}
|
|
|
|
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
|
|
privateMessageNotifications.append((senderName, message, peerID))
|
|
}
|
|
|
|
// System messages
|
|
private(set) var systemMessages: [String] = []
|
|
private(set) var meshOnlySystemMessages: [String] = []
|
|
|
|
func addSystemMessage(_ content: String) {
|
|
systemMessages.append(content)
|
|
}
|
|
|
|
func addMeshOnlySystemMessage(_ content: String) {
|
|
meshOnlySystemMessages.append(content)
|
|
}
|
|
|
|
static let dummyIdentity = NostrIdentity(
|
|
privateKey: Data(repeating: 0x11, count: 32),
|
|
publicKey: Data(repeating: 0x22, count: 32),
|
|
npub: "npub1mock",
|
|
createdAt: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func makeIncomingMessage(
|
|
id: String,
|
|
sender: String = "alice",
|
|
content: String = "hello",
|
|
timestamp: Date = Date(),
|
|
senderPeerID: PeerID? = nil,
|
|
recipientNickname: String? = "me"
|
|
) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: sender,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: recipientNickname,
|
|
senderPeerID: senderPeerID,
|
|
deliveryStatus: .delivered(to: "me", at: timestamp)
|
|
)
|
|
}
|
|
|
|
private func isDelivered(_ status: DeliveryStatus?, to expected: String) -> Bool {
|
|
if case .delivered(let to, _) = status { return to == expected }
|
|
return false
|
|
}
|
|
|
|
private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
|
|
if case .read(let by, _) = status { return by == expected }
|
|
return false
|
|
}
|
|
|
|
private func makeFavoriteRelationship(
|
|
noiseKey: Data,
|
|
nostrPublicKey: String? = nil,
|
|
nickname: String = "alice",
|
|
isFavorite: Bool = false,
|
|
theyFavoritedUs: Bool = false
|
|
) -> FavoritesPersistenceService.FavoriteRelationship {
|
|
FavoritesPersistenceService.FavoriteRelationship(
|
|
peerNoisePublicKey: noiseKey,
|
|
peerNostrPublicKey: nostrPublicKey,
|
|
peerNickname: nickname,
|
|
isFavorite: isFavorite,
|
|
theyFavoritedUs: theyFavoritedUs,
|
|
favoritedAt: Date(timeIntervalSince1970: 0),
|
|
lastUpdated: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatPrivateConversationCoordinator` against
|
|
/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the
|
|
/// pure-state and ack flows plus — now that notifications and favorites are
|
|
/// injected through the context (`notifyPrivateMessage`,
|
|
/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) — the
|
|
/// notification and favorite-transition flows that previously required the
|
|
/// live singletons.
|
|
struct ChatPrivateConversationCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func addMessageToPrivateChats_upsertsByIdAndSanitizes() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
|
|
let original = makeIncomingMessage(id: "m1", content: "first")
|
|
coordinator.addMessageToPrivateChatsIfNeeded(original, targetPeerID: peerID)
|
|
#expect(context.privateChats[peerID]?.map(\.id) == ["m1"])
|
|
|
|
// Same id again must replace in place, not append a duplicate.
|
|
let updated = makeIncomingMessage(id: "m1", content: "edited")
|
|
coordinator.addMessageToPrivateChatsIfNeeded(updated, targetPeerID: peerID)
|
|
#expect(context.privateChats[peerID]?.count == 1)
|
|
#expect(context.privateChats[peerID]?.first?.content == "edited")
|
|
|
|
// A different id appends.
|
|
coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID)
|
|
#expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"])
|
|
// Every add went through the store's upsert intent.
|
|
#expect(context.upsertedMessages.map(\.peerID) == [peerID, peerID, peerID])
|
|
#expect(context.upsertedMessages.map(\.messageID) == ["m1", "m1", "m2"])
|
|
|
|
#expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID))
|
|
#expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func geoDeliveredAndReadAcks_updateStatusAndNotify() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let convKey = PeerID(str: "nostr_abcdef12")
|
|
let senderPubkey = "feedface00112233"
|
|
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
|
context.privateChats[convKey] = [
|
|
makeIncomingMessage(id: "mine-1", sender: "me"),
|
|
makeIncomingMessage(id: "mine-2", sender: "me")
|
|
]
|
|
|
|
coordinator.handleDelivered(
|
|
NoisePayload(type: .delivered, data: Data("mine-1".utf8)),
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey
|
|
)
|
|
#expect(isDelivered(context.privateChats[convKey]?.first?.deliveryStatus, to: "alice#1234"))
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
|
|
coordinator.handleReadReceipt(
|
|
NoisePayload(type: .readReceipt, data: Data("mine-2".utf8)),
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey
|
|
)
|
|
#expect(isRead(context.privateChats[convKey]?.last?.deliveryStatus, by: "alice#1234"))
|
|
#expect(context.notifyUIChangedCount == 2)
|
|
|
|
// Unknown message id: no state change, no UI notification.
|
|
coordinator.handleDelivered(
|
|
NoisePayload(type: .delivered, data: Data("missing".utf8)),
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey
|
|
)
|
|
#expect(context.notifyUIChangedCount == 2)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func geoPrivateMessage_sendsDeliveryAckOnceAndDeduplicates() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let convKey = PeerID(str: "nostr_abcdef12")
|
|
let senderPubkey = "feedface00112233"
|
|
context.displayNamesByPubkey[senderPubkey] = "bob#5678"
|
|
let payloadData = PrivateMessagePacket(messageID: "geo-1", content: "hi there").encode()!
|
|
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
|
// Old timestamp: not "recent", so no unread marking (and no notification).
|
|
let oldTimestamp = Date().addingTimeInterval(-120)
|
|
|
|
coordinator.handlePrivateMessage(
|
|
payload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey,
|
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
|
messageTimestamp: oldTimestamp
|
|
)
|
|
|
|
#expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"])
|
|
#expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey)
|
|
#expect(context.sentGeoDeliveryAcks == ["geo-1"])
|
|
#expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"])
|
|
#expect(context.privateChats[convKey]?.first?.sender == "bob#5678")
|
|
#expect(context.unreadPrivateMessages.isEmpty)
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
|
|
// Redelivery: ack is deduplicated and the message is not appended twice.
|
|
coordinator.handlePrivateMessage(
|
|
payload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey,
|
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
|
messageTimestamp: oldTimestamp
|
|
)
|
|
#expect(context.geoDeliveryAcks.count == 1)
|
|
#expect(context.privateChats[convKey]?.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
|
let stablePeerID = PeerID(hexData: noiseKey)
|
|
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
|
context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID
|
|
context.unreadPrivateMessages = [stablePeerID, ephemeralPeerID]
|
|
let message = makeIncomingMessage(id: "read-1", senderPeerID: stablePeerID)
|
|
|
|
coordinator.handleViewingThisChat(
|
|
message,
|
|
targetPeerID: stablePeerID,
|
|
key: noiseKey,
|
|
senderPubkey: "feedface00112233"
|
|
)
|
|
|
|
#expect(context.unreadPrivateMessages.isEmpty)
|
|
#expect(context.routedReadReceipts.map(\.messageID) == ["read-1"])
|
|
#expect(context.routedReadReceipts.first?.peerID == stablePeerID)
|
|
#expect(context.sentReadReceipts == ["read-1"])
|
|
|
|
// Already-acked message must not produce a second receipt.
|
|
coordinator.handleViewingThisChat(
|
|
message,
|
|
targetPeerID: stablePeerID,
|
|
key: noiseKey,
|
|
senderPubkey: "feedface00112233"
|
|
)
|
|
#expect(context.routedReadReceipts.count == 1)
|
|
|
|
// Without a Noise key, the receipt goes out via the geohash transport.
|
|
context.sentReadReceipts = []
|
|
coordinator.handleViewingThisChat(
|
|
message,
|
|
targetPeerID: stablePeerID,
|
|
key: nil,
|
|
senderPubkey: "feedface00112233"
|
|
)
|
|
#expect(context.geoReadReceipts.map(\.messageID) == ["read-1"])
|
|
#expect(context.sentReadReceipts == ["read-1"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func markAsUnread_tracksTargetAndEphemeralWithoutNotificationWhenStale() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xCD, count: 32)
|
|
let stablePeerID = PeerID(hexData: noiseKey)
|
|
let ephemeralPeerID = PeerID(str: "1112131415161718")
|
|
context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID
|
|
|
|
// isRecentMessage false keeps the flow off the NotificationService singleton.
|
|
coordinator.markAsUnreadIfNeeded(
|
|
shouldMarkAsUnread: true,
|
|
targetPeerID: stablePeerID,
|
|
key: noiseKey,
|
|
isRecentMessage: false,
|
|
senderNickname: "alice",
|
|
messageContent: "hello"
|
|
)
|
|
#expect(context.unreadPrivateMessages == [stablePeerID, ephemeralPeerID])
|
|
|
|
context.unreadPrivateMessages = []
|
|
coordinator.markAsUnreadIfNeeded(
|
|
shouldMarkAsUnread: false,
|
|
targetPeerID: stablePeerID,
|
|
key: noiseKey,
|
|
isRecentMessage: false,
|
|
senderNickname: "alice",
|
|
messageContent: "hello"
|
|
)
|
|
#expect(context.unreadPrivateMessages.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func migratePrivateChats_movesMessagesOnFingerprintMatchAndClearsOldPeer() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
|
let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb")
|
|
context.fingerprintsByPeerID[oldPeerID] = "fp-1"
|
|
context.fingerprintsByPeerID[newPeerID] = "fp-1"
|
|
let older = makeIncomingMessage(id: "old-1", timestamp: Date().addingTimeInterval(-60))
|
|
let newer = makeIncomingMessage(id: "old-2", timestamp: Date().addingTimeInterval(-30))
|
|
context.privateChats[oldPeerID] = [newer, older]
|
|
context.unreadPrivateMessages = [oldPeerID]
|
|
context.selectedPrivateChatPeer = oldPeerID
|
|
|
|
coordinator.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice")
|
|
|
|
#expect(context.privateChats[oldPeerID] == nil)
|
|
#expect(context.privateChats[newPeerID]?.map(\.id) == ["old-1", "old-2"])
|
|
#expect(context.unreadPrivateMessages.isEmpty)
|
|
#expect(context.clearedFingerprints == [oldPeerID])
|
|
#expect(context.selectedPrivateChatPeer == newPeerID)
|
|
// The wholesale move went through the store's migrate intent.
|
|
#expect(context.migratedChats.map(\.from) == [oldPeerID])
|
|
#expect(context.migratedChats.map(\.to) == [newPeerID])
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handlePrivateMessage_postsNotificationOnlyWhenNotViewingChat() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
|
|
// Not viewing: marked unread and a local notification is posted.
|
|
coordinator.handlePrivateMessage(
|
|
makeIncomingMessage(id: "pm-1", content: "hi there", senderPeerID: peerID)
|
|
)
|
|
#expect(context.unreadPrivateMessages == [peerID])
|
|
#expect(context.privateMessageNotifications.count == 1)
|
|
#expect(context.privateMessageNotifications.first?.senderName == "alice")
|
|
#expect(context.privateMessageNotifications.first?.message == "hi there")
|
|
#expect(context.privateMessageNotifications.first?.peerID == peerID)
|
|
#expect(context.meshReadReceipts.isEmpty)
|
|
|
|
// Viewing the chat: a READ ack is sent instead and no notification fires.
|
|
context.selectedPrivateChatPeer = peerID
|
|
coordinator.handlePrivateMessage(makeIncomingMessage(id: "pm-2", senderPeerID: peerID))
|
|
#expect(context.meshReadReceipts.map(\.messageID) == ["pm-2"])
|
|
#expect(context.sentReadReceipts.contains("pm-2"))
|
|
#expect(context.privateMessageNotifications.count == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
|
let peerID = PeerID(hexData: noiseKey)
|
|
|
|
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
|
|
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
|
#expect(context.peerFavoritedUsUpdates.count == 1)
|
|
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
|
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
|
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
|
|
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
|
|
|
// Same state again: store write still happens, but no repeat announcement.
|
|
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
|
noiseKey: noiseKey,
|
|
theyFavoritedUs: true
|
|
)
|
|
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
|
#expect(context.peerFavoritedUsUpdates.count == 2)
|
|
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
|
|
|
// [UNFAVORITED] transition announces again.
|
|
coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice")
|
|
#expect(context.peerFavoritedUsUpdates.last?.favorited == false)
|
|
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
|
|
}
|
|
|
|
/// A Nostr DM whose sender resolved to a known noise key must be labeled
|
|
/// with the favorite's nickname, not the geohash-scoped anon fallback.
|
|
@Test @MainActor
|
|
func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xDA, count: 32)
|
|
let convKey = PeerID(hexData: noiseKey)
|
|
let senderPubkey = "0badc0de00112233"
|
|
// No displayNamesByPubkey entry: the geo fallback would be "anon".
|
|
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
|
noiseKey: noiseKey,
|
|
nostrPublicKey: "npub1bob",
|
|
nickname: "bob",
|
|
isFavorite: true,
|
|
theyFavoritedUs: true
|
|
)
|
|
|
|
let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()!
|
|
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
|
|
|
coordinator.handlePrivateMessage(
|
|
payload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey,
|
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
|
messageTimestamp: Date()
|
|
)
|
|
|
|
#expect(context.privateChats[convKey]?.first?.sender == "bob")
|
|
}
|
|
|
|
/// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey
|
|
/// path; they must update the relationship, not render as chat text.
|
|
@Test @MainActor
|
|
func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xEE, count: 32)
|
|
// The inbound pipeline resolves known favorites to their noise-key ID.
|
|
let convKey = PeerID(hexData: noiseKey)
|
|
let senderPubkey = "feedface99887766"
|
|
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
|
|
|
let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()!
|
|
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
|
|
|
coordinator.handlePrivateMessage(
|
|
payload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey,
|
|
id: MockChatPrivateConversationContext.dummyIdentity,
|
|
messageTimestamp: Date()
|
|
)
|
|
|
|
#expect(context.peerFavoritedUsUpdates.count == 1)
|
|
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
|
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
|
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
|
|
#expect(context.privateChats[convKey, default: []].isEmpty)
|
|
#expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xCD, count: 32)
|
|
let peerID = PeerID(hexData: noiseKey)
|
|
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
|
noiseKey: noiseKey,
|
|
nostrPublicKey: "npub1bob",
|
|
nickname: "bob",
|
|
isFavorite: true,
|
|
theyFavoritedUs: true
|
|
)
|
|
|
|
coordinator.sendPrivateMessage("hello bob", to: peerID)
|
|
|
|
// Offline but mutual favorite with a Nostr key: routed, marked sent,
|
|
// and the nickname falls back to the favorite relationship.
|
|
#expect(context.routedPrivateMessages.map(\.content) == ["hello bob"])
|
|
#expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent)
|
|
#expect(context.privateChats[peerID]?.first?.recipientNickname == "bob")
|
|
#expect(context.systemMessages.isEmpty)
|
|
}
|
|
|
|
/// Same as above, but the conversation is keyed by the SHORT mesh ID —
|
|
/// the DM window was opened while the peer was on mesh, then they went
|
|
/// out of range. The favorite must resolve via the derived short ID and
|
|
/// route over Nostr instead of failing "peer not reachable".
|
|
@Test @MainActor
|
|
func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let noiseKey = Data(repeating: 0xCE, count: 32)
|
|
let shortID = PeerID(publicKey: noiseKey)
|
|
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
|
noiseKey: noiseKey,
|
|
nostrPublicKey: "npub1bob",
|
|
nickname: "bob",
|
|
isFavorite: true,
|
|
theyFavoritedUs: true
|
|
)
|
|
|
|
coordinator.sendPrivateMessage("hello again", to: shortID)
|
|
|
|
#expect(context.routedPrivateMessages.map(\.content) == ["hello again"])
|
|
#expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent)
|
|
#expect(context.privateChats[shortID]?.first?.recipientNickname == "bob")
|
|
#expect(context.systemMessages.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
|
let context = MockChatPrivateConversationContext()
|
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
|
let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32))
|
|
|
|
coordinator.sendPrivateMessage("hello?", to: peerID)
|
|
|
|
#expect(context.routedPrivateMessages.isEmpty)
|
|
#expect(context.systemMessages.count == 1)
|
|
guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else {
|
|
Issue.record("expected .failed delivery status")
|
|
return
|
|
}
|
|
}
|
|
}
|