mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:45:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66c591f8e | ||
|
|
75da63c9d7 |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.3
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
Generated
+4
-4
@@ -561,7 +561,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -620,7 +620,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -655,7 +655,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -749,7 +749,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
MARKETING_VERSION = 1.5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -137,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
// Relay URLs are normalized before entries are created, so direct
|
||||
// set membership is sound.
|
||||
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
|
||||
}
|
||||
|
||||
/// A relay that drops before sending EOSE must not stall initial-load
|
||||
|
||||
@@ -2577,9 +2577,14 @@ extension BLEService {
|
||||
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||
return
|
||||
}
|
||||
// Mesh peer IDs are derived from Noise static keys, so the sender
|
||||
// resolves to the same DM thread whether or not they're present.
|
||||
let senderPeerID = PeerID(publicKey: senderStaticKey)
|
||||
// A present sender resolves to their live mesh thread via the
|
||||
// derived short ID. An absent sender — the usual courier case —
|
||||
// uses the full noise-key ID so the message lands on the stable
|
||||
// favorite conversation instead of an unresolvable short-ID
|
||||
// thread labeled "Unknown".
|
||||
let shortID = PeerID(publicKey: senderStaticKey)
|
||||
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||||
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
|
||||
let payload = Data(typedPayload.dropFirst())
|
||||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||
notifyUI { [weak self] in
|
||||
@@ -2757,8 +2762,11 @@ extension BLEService {
|
||||
// MARK: Private Message Handling
|
||||
|
||||
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
|
||||
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
|
||||
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
|
||||
let recipientID = recipientID.toShort()
|
||||
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
|
||||
|
||||
|
||||
// Check if we have an established Noise session
|
||||
if noiseService.hasEstablishedSession(with: recipientID) {
|
||||
// Encrypt and send
|
||||
|
||||
@@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -335,34 +336,31 @@ final class CommandProcessor {
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
if add {
|
||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||
let isCurrentlyFavorite: Bool
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
peerNostrPublicKey: String? = nil
|
||||
) {
|
||||
let existing = favorites[peerNoisePublicKey]
|
||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||
// Callers that can't resolve the live nickname pass the "Unknown"
|
||||
// placeholder (e.g. a notification arriving before the announce);
|
||||
// never let it clobber a real stored nickname.
|
||||
let incoming = peerNickname.flatMap { name in
|
||||
(name.isEmpty || name == "Unknown") ? nil : name
|
||||
}
|
||||
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
||||
|
||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ struct CourierDirectory {
|
||||
static func favoritesBacked() -> CourierDirectory {
|
||||
CourierDirectory(
|
||||
noiseKey: { peerID in
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
|
||||
// Offline favorites are addressed by the full 64-hex
|
||||
// noise-key ID, which carries the key itself; the favorites
|
||||
// lookup only resolves short 16-hex IDs.
|
||||
peerID.noiseKey
|
||||
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
|
||||
},
|
||||
isTrustedCourier: { noiseKey in
|
||||
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
|
||||
@@ -124,6 +128,14 @@ final class MessageRouter {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
// may never come. Double delivery is harmless — receivers dedup
|
||||
// by message ID, and delivered/read acks never downgrade.
|
||||
if !transport.canDeliverPromptly(to: peerID) {
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
}
|
||||
} else {
|
||||
var unsent = message
|
||||
unsent.sendAttempts = 0
|
||||
@@ -133,11 +145,12 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last resort when no transport can reach the peer: seal the message to
|
||||
/// their known static key and hand it to connected mutual favorites who
|
||||
/// may physically encounter them. The queued copy above stays retained,
|
||||
/// so direct delivery still wins if the peer reappears first (receivers
|
||||
/// dedup by message ID).
|
||||
/// Last resort when no transport can deliver promptly — the peer is
|
||||
/// unreachable, or only reachable through a send queue waiting on
|
||||
/// internet: seal the message to their known static key and hand it to
|
||||
/// connected mutual favorites who may physically encounter them. The
|
||||
/// queued copy above stays retained, so direct delivery still wins if
|
||||
/// the peer reappears first (receivers dedup by message ID).
|
||||
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
||||
for transport in transports {
|
||||
|
||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// still queue.
|
||||
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
||||
|
||||
@MainActor
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +56,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
// Mirror of the relay manager's connection state, cached here because
|
||||
// canDeliverPromptly is called synchronously off the main actor.
|
||||
private var relaysConnected = false
|
||||
private var relayConnectivityCancellable: AnyCancellable?
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.reachablePeers = Set(reachable)
|
||||
}
|
||||
|
||||
relayConnectivityCancellable = self.dependencies.relayConnectivity()
|
||||
.sink { [weak self] connected in
|
||||
guard let self else { return }
|
||||
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -125,16 +142,22 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
// Check if exact match
|
||||
// Callers address peers by either the short 16-hex ID or the full
|
||||
// 64-hex noise key (offline favorites), so compare in short form.
|
||||
let short = peerID.toShort()
|
||||
return queue.sync {
|
||||
if reachablePeers.contains(peerID) { return true }
|
||||
// Check for short ID match
|
||||
if peerID.isShort {
|
||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
||||
}
|
||||
return false
|
||||
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||
}
|
||||
}
|
||||
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
// A known npub makes a peer "reachable", but with no relay
|
||||
// connection a send only joins the local queue. Answering honestly
|
||||
// here lets the router hand a sealed copy to a courier in parallel
|
||||
// instead of waiting for internet that may never come.
|
||||
isPeerReachable(peerID) && queue.sync { relaysConnected }
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
@@ -54,6 +54,11 @@ protocol Transport: AnyObject {
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
/// Whether a send to this peer is likely to leave the device promptly.
|
||||
/// Distinct from reachability: Nostr claims any favorite with a known
|
||||
/// npub as reachable even with no relay connection, where a send only
|
||||
/// joins a queue waiting for internet that may never come.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
@@ -111,6 +116,10 @@ protocol Transport: AnyObject {
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
// Reachability implies prompt delivery for transports that hand packets
|
||||
// straight to the radio; queue-backed transports override this.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
|
||||
|
||||
// Noise identity hooks default to inert for transports that do not carry
|
||||
// Noise sessions (e.g. NostrTransport).
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||
|
||||
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
var meshNoiseKeys: Set<Data> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites,
|
||||
meshAttached: hasAnyConnected
|
||||
)
|
||||
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
if peer.isConnected { connected.insert(peerID) }
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
if let publicKey = peerInfo.noisePublicKey {
|
||||
meshNoiseKeys.insert(publicKey)
|
||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite.
|
||||
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
|
||||
// the full 32-byte noise key, so dedup must compare noise keys — a
|
||||
// PeerID comparison between the two forms can never match.
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
|
||||
// Skip if connected under different ID but same nickname
|
||||
let isConnectedByNickname = enrichedPeers.contains {
|
||||
$0.nickname == favorite.peerNickname && $0.isConnected
|
||||
}
|
||||
if isConnectedByNickname { continue }
|
||||
|
||||
|
||||
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||
enrichedPeers.append(peer)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
||||
}
|
||||
|
||||
@@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
|
||||
// MARK: Favorites & notifications (shared with the other contexts)
|
||||
// MARK: Favorites (shared with the other contexts)
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Adds (or updates) a favorite in the favorites store.
|
||||
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
|
||||
/// Posts a generic local user notification.
|
||||
func postLocalNotification(title: String, body: String, identifier: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatNostrContext {
|
||||
@@ -98,57 +94,6 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
guard let context else { return }
|
||||
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
|
||||
|
||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||
|
||||
if isFavorite {
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: nostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
var extractedNostrPubkey: String?
|
||||
if let range = content.range(of: "NPUB:") {
|
||||
let suffix = content[range.upperBound...]
|
||||
let parts = suffix.components(separatedBy: "|")
|
||||
if let key = parts.first {
|
||||
extractedNostrPubkey = String(key)
|
||||
}
|
||||
} else if content.contains(":") {
|
||||
let parts = content.components(separatedBy: ":")
|
||||
if parts.count >= 3 {
|
||||
extractedNostrPubkey = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||
|
||||
if isFavorite && extractedNostrPubkey != nil {
|
||||
SecureLogger.info(
|
||||
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
||||
category: .session
|
||||
)
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: extractedNostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
context.postLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
guard let context else { return }
|
||||
|
||||
@@ -92,7 +92,6 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
|
||||
|
||||
// MARK: System messages
|
||||
func addSystemMessage(_ content: String)
|
||||
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
// 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.
|
||||
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
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,
|
||||
@@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
guard let noiseKey = Data(hexString: peerID.id) else { 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 = context.favoriteRelationship(forNoiseKey: noiseKey)
|
||||
let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
|
||||
?? context.favoriteRelationship(forPeerID: peerID)
|
||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||
|
||||
@@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator {
|
||||
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
|
||||
}
|
||||
|
||||
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||
|
||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderName,
|
||||
@@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator {
|
||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
}
|
||||
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
let messageContent = pm.content
|
||||
|
||||
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
|
||||
if let key = actualSenderNoiseKey {
|
||||
handleFavoriteNotificationFromMesh(
|
||||
messageContent,
|
||||
from: PeerID(hexData: key),
|
||||
senderNickname: senderNickname
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
|
||||
return
|
||||
}
|
||||
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
|
||||
var isViewingThisChat = false
|
||||
if context.selectedPrivateChatPeer == targetPeerID {
|
||||
isViewingThisChat = true
|
||||
} else if let selectedPeer = context.selectedPrivateChatPeer,
|
||||
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
|
||||
let key = actualSenderNoiseKey,
|
||||
selectedPeerNoiseKey == key {
|
||||
isViewingThisChat = true
|
||||
}
|
||||
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderNickname,
|
||||
content: messageContent,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: targetPeerID,
|
||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||
)
|
||||
|
||||
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
|
||||
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
|
||||
|
||||
context.sendDeliveryAckViaNostrEmbedded(
|
||||
message,
|
||||
wasReadBefore: wasReadBefore,
|
||||
senderPubkey: senderPubkey,
|
||||
key: actualSenderNoiseKey
|
||||
)
|
||||
|
||||
if wasReadBefore {
|
||||
// No-op.
|
||||
} else if isViewingThisChat {
|
||||
handleViewingThisChat(
|
||||
message,
|
||||
targetPeerID: targetPeerID,
|
||||
key: actualSenderNoiseKey,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
} else {
|
||||
markAsUnreadIfNeeded(
|
||||
shouldMarkAsUnread: shouldMarkAsUnread,
|
||||
targetPeerID: targetPeerID,
|
||||
key: actualSenderNoiseKey,
|
||||
isRecentMessage: isRecentMessage,
|
||||
senderNickname: senderNickname,
|
||||
messageContent: messageContent
|
||||
)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||
@@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
||||
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
|
||||
handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,7 +653,10 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
/// 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: ":")
|
||||
|
||||
|
||||
@@ -109,11 +109,6 @@ extension ChatViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||
|
||||
@@ -121,25 +121,6 @@ extension ChatViewModel {
|
||||
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
privateConversationCoordinator.handlePrivateMessage(
|
||||
payload,
|
||||
actualSenderNoiseKey: actualSenderNoiseKey,
|
||||
senderNickname: senderNickname,
|
||||
targetPeerID: targetPeerID,
|
||||
messageTimestamp: messageTimestamp,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
privateConversationCoordinator.handlePrivateMessage(message)
|
||||
@@ -190,8 +171,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
|
||||
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotification(
|
||||
content,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname
|
||||
|
||||
@@ -442,8 +442,7 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
||||
/// store. Lives here because the inbound DM path needs it per message;
|
||||
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
||||
/// store. Lives here because the inbound DM path needs it per message.
|
||||
@MainActor
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
guard let context else { return nil }
|
||||
|
||||
@@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests {
|
||||
#expect(stamped > stale)
|
||||
#expect(context.appendedGeohashMessages.count == 1)
|
||||
}
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let noiseKey = Data(repeating: 0x42, count: 32)
|
||||
// The favorites store bridges the sender's npub back to a Noise key.
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: sender.npub
|
||||
)
|
||||
|
||||
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
|
||||
|
||||
#expect(context.addedFavorites.count == 1)
|
||||
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
|
||||
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
|
||||
#expect(context.addedFavorites.first?.nickname == "alice")
|
||||
#expect(context.postedLocalNotifications.count == 1)
|
||||
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
|
||||
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
|
||||
|
||||
// Unfavorite: no store write, but the removal notification still posts.
|
||||
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
|
||||
#expect(context.addedFavorites.count == 1)
|
||||
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
|
||||
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
||||
|
||||
@@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
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))
|
||||
}
|
||||
@@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
|
||||
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.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
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)
|
||||
@@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
noiseKey: noiseKey,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.count == 2)
|
||||
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
||||
|
||||
// [UNFAVORITED] transition announces again.
|
||||
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice")
|
||||
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()
|
||||
@@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#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()
|
||||
|
||||
@@ -655,8 +655,10 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||
}
|
||||
|
||||
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
|
||||
/// out of the conversation transcript.
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
||||
func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||
@@ -664,19 +666,33 @@ struct ChatViewModelNostrExtensionTests {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Before"
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
defer {
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
viewModel.handleFavoriteNotification(
|
||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
||||
from: identity.publicKeyHex
|
||||
// The inbound pipeline resolves a known sender to their noise-key ID.
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let payloadData = try #require(
|
||||
PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode()
|
||||
)
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: identity.publicKeyHex,
|
||||
convKey: convKey,
|
||||
id: identity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
#expect(relationship?.peerNickname == "Alice")
|
||||
#expect(relationship?.theyFavoritedUs == true)
|
||||
#expect(relationship?.isMutual == true)
|
||||
#expect(relationship?.peerNostrPublicKey == identity.npub)
|
||||
#expect(relationship?.isFavorite == true)
|
||||
#expect(viewModel.privateChats[convKey, default: []].isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -303,6 +303,50 @@ struct CommandProcessorTests {
|
||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
}
|
||||
|
||||
/// /fav must go through toggleFavorite (which persists by the real noise
|
||||
/// key) — not write the hex peer ID into the favorites store, and not
|
||||
/// send a second favorite notification.
|
||||
@MainActor
|
||||
@Test func favoriteCommandTogglesWithoutDirectStoreWrite() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let processor = CommandProcessor(
|
||||
contextProvider: context,
|
||||
meshService: MockTransport(),
|
||||
identityManager: identityManager
|
||||
)
|
||||
let peerID = PeerID(str: "00aa00bb00cc00dd")
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/fav alice")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "added alice to favorites")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.toggledFavorites == [peerID])
|
||||
#expect(context.favoriteNotifications.isEmpty)
|
||||
// The 8-byte routing ID must never be stored as a "noise key".
|
||||
let bogusKey = Data(hexString: peerID.id)!
|
||||
#expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil)
|
||||
|
||||
// Unfavoriting someone who is not a favorite is a no-op.
|
||||
let unfavResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/unfav alice")
|
||||
}
|
||||
switch unfavResult {
|
||||
case .success(let message):
|
||||
#expect(message == "alice is not a favorite")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.toggledFavorites == [peerID])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
|
||||
@@ -170,8 +170,12 @@ struct CourierEndToEndTests {
|
||||
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
#expect(delivered.type == .privateMessage)
|
||||
// Sender resolves to Alice's stable mesh identity, not the courier's.
|
||||
#expect(delivered.peerID == alice.myPeerID)
|
||||
// Alice is absent from Bob's mesh, so the sender resolves to her
|
||||
// full noise-key ID — the stable favorite conversation — not the
|
||||
// short mesh ID (which Bob couldn't resolve to a nickname) and not
|
||||
// the courier's identity.
|
||||
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||
#expect(delivered.peerID != carol.myPeerID)
|
||||
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||
#expect(message.messageID == "courier-msg-1")
|
||||
#expect(message.content == "the camp moved north")
|
||||
@@ -540,7 +544,16 @@ private final class CourierCaptureTransport: Transport {
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||
snapshots.contains { $0.peerID == peerID && $0.isConnected }
|
||||
}
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) }
|
||||
// Nostr-style reachability: claimed for peers with no live link (known
|
||||
// npub), where prompt delivery additionally needs a relay connection.
|
||||
var reachablePeers: Set<PeerID> = []
|
||||
var promptDelivery = true
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
isPeerConnected(peerID) || reachablePeers.contains(peerID)
|
||||
}
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
isPeerReachable(peerID) && promptDelivery
|
||||
}
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
@@ -617,6 +630,21 @@ struct MessageRouterCourierTests {
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
|
||||
/// The production directory must resolve both ID forms: a 64-hex
|
||||
/// noise-key ID (offline favorite row) carries the key itself, and a
|
||||
/// short 16-hex ID resolves through the favorites store.
|
||||
@Test @MainActor
|
||||
func favoritesBackedDirectoryResolvesBothIDForms() {
|
||||
let directory = CourierDirectory.favoritesBacked()
|
||||
let bobKey = Data(repeating: 0xB7, count: 32)
|
||||
|
||||
#expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey)
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob")
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) }
|
||||
#expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func reachablePeerSkipsCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
@@ -633,4 +661,70 @@ struct MessageRouterCourierTests {
|
||||
#expect(transport.directSends == ["m3"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
}
|
||||
|
||||
/// A peer can be "reachable" through a transport that cannot deliver
|
||||
/// promptly (Nostr claims any favorite with a known npub, even with no
|
||||
/// relay connection). The queued send must not shadow the courier: a
|
||||
/// sealed copy goes to connected couriers in parallel, and receivers
|
||||
/// dedup by message ID if both arrive.
|
||||
@Test @MainActor
|
||||
func queuedReachableSendAlsoDepositsWithCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
|
||||
]
|
||||
transport.reachablePeers = [bobID]
|
||||
transport.promptDelivery = false
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m4")
|
||||
|
||||
#expect(transport.directSends == ["m4"])
|
||||
#expect(transport.courierSends.count == 1)
|
||||
#expect(transport.courierSends.first?.messageID == "m4")
|
||||
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||
#expect(carried == ["m4"])
|
||||
}
|
||||
|
||||
/// When the reachable transport can deliver promptly (relays up), the
|
||||
/// send is trusted and no courier quota is spent.
|
||||
@Test @MainActor
|
||||
func promptlyDeliverableReachablePeerSkipsCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
|
||||
]
|
||||
transport.reachablePeers = [bobID]
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m5")
|
||||
|
||||
#expect(transport.directSends == ["m5"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
XCTAssertFalse(service.isMutualFavorite(peerKey))
|
||||
}
|
||||
|
||||
func test_updatePeerFavoritedUs_keepsStoredNicknameOverUnknownPlaceholder() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((128..<160).map(UInt8.init))
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Erin")
|
||||
|
||||
// A notification arriving before the peer is known passes "Unknown".
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Unknown")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin")
|
||||
|
||||
// A real nickname still updates the stored one.
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Erin2")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin2")
|
||||
}
|
||||
|
||||
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((96..<128).map(UInt8.init))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@@ -42,7 +43,9 @@ struct NostrTransportTests {
|
||||
)
|
||||
)
|
||||
|
||||
#expect(!transport.isPeerReachable(fullPeerID))
|
||||
// Offline favorites are addressed by the full 64-hex noise key, so
|
||||
// both forms must resolve to the same reachability answer.
|
||||
#expect(transport.isPeerReachable(fullPeerID))
|
||||
#expect(transport.isPeerReachable(shortPeerID))
|
||||
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
||||
}
|
||||
@@ -83,6 +86,51 @@ struct NostrTransportTests {
|
||||
#expect(didRefresh)
|
||||
}
|
||||
|
||||
@Test("Prompt delivery requires both a known npub and a relay connection")
|
||||
@MainActor
|
||||
func canDeliverPromptlyTracksRelayConnectivity() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
let connectivity = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
loadFavorites: { [noiseKey: relationship] },
|
||||
relayConnectivity: { connectivity.eraseToAnyPublisher() }
|
||||
)
|
||||
)
|
||||
|
||||
// Reachable (npub known) but relays down: the peer must not be
|
||||
// treated as promptly deliverable, or the router would skip the
|
||||
// courier and let the message rot in the Nostr send queue.
|
||||
#expect(transport.isPeerReachable(peerID))
|
||||
#expect(!transport.canDeliverPromptly(to: peerID))
|
||||
|
||||
connectivity.send(true)
|
||||
let deliverable = await TestHelpers.waitUntil(
|
||||
{ transport.canDeliverPromptly(to: peerID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(deliverable)
|
||||
|
||||
connectivity.send(false)
|
||||
let undeliverable = await TestHelpers.waitUntil(
|
||||
{ !transport.canDeliverPromptly(to: peerID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(undeliverable)
|
||||
}
|
||||
|
||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||
@MainActor
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
@@ -373,7 +421,8 @@ struct NostrTransportTests {
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -383,7 +432,8 @@ struct NostrTransportTests {
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
sendEvent: sendEvent,
|
||||
scheduleAfter: scheduleAfter
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,117 @@ struct UnifiedPeerServiceTests {
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
// MARK: - Offline-favorite dedup (updatePeers phase 2)
|
||||
|
||||
/// A mutual favorite that is also on the mesh must collapse to a single
|
||||
/// row keyed by the short mesh ID — even when the announced nickname no
|
||||
/// longer matches the one stored with the favorite.
|
||||
@Test @MainActor
|
||||
func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
let meshID = PeerID(publicKey: noiseKey)
|
||||
let snapshots = [TransportPeerSnapshot(
|
||||
peerID: meshID,
|
||||
nickname: "alice-renamed",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)]
|
||||
transport.updatePeerSnapshots(snapshots)
|
||||
service.didUpdatePeerSnapshots(snapshots)
|
||||
|
||||
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.peerID == meshID)
|
||||
#expect(rows.first?.isMutualFavorite == true)
|
||||
#expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1)
|
||||
}
|
||||
|
||||
/// Same collapse must hold for a reachable-but-not-connected favorite
|
||||
/// (relayed peers linger as "reachable" after their link drops).
|
||||
@Test @MainActor
|
||||
func updatePeers_reachableMutualFavoriteYieldsSingleRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xCD, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
let otherKey = Data(repeating: 0x11, count: 32)
|
||||
let snapshots = [
|
||||
// A live link is required for anyone to count as reachable.
|
||||
TransportPeerSnapshot(
|
||||
peerID: PeerID(publicKey: otherKey),
|
||||
nickname: "carol",
|
||||
isConnected: true,
|
||||
noisePublicKey: otherKey,
|
||||
lastSeen: Date()
|
||||
),
|
||||
TransportPeerSnapshot(
|
||||
peerID: PeerID(publicKey: noiseKey),
|
||||
nickname: "bob",
|
||||
isConnected: false,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
]
|
||||
transport.updatePeerSnapshots(snapshots)
|
||||
service.didUpdatePeerSnapshots(snapshots)
|
||||
|
||||
let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(bobRows.count == 1)
|
||||
#expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey))
|
||||
#expect(bobRows.first?.isReachable == true)
|
||||
}
|
||||
|
||||
/// A mutual favorite with no mesh presence still gets its offline row,
|
||||
/// keyed by the full noise-key PeerID.
|
||||
@Test @MainActor
|
||||
func updatePeers_offlineMutualFavoriteGetsOfflineRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xEF, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
transport.updatePeerSnapshots([])
|
||||
service.didUpdatePeerSnapshots([])
|
||||
|
||||
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.peerID == PeerID(hexData: noiseKey))
|
||||
#expect(rows.first?.isMutualFavorite == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_unknownIdentityReturnsNil() async {
|
||||
let transport = MockTransport()
|
||||
|
||||
Reference in New Issue
Block a user