mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:25:22 +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
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
Generated
+4
-4
@@ -561,7 +561,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -620,7 +620,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -749,7 +749,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.3;
|
MARKETING_VERSION = 1.5.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@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 let dependencies: NostrRelayManagerDependencies
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
isConnected = relays.contains { $0.isConnected }
|
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
|
/// 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)
|
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Mesh peer IDs are derived from Noise static keys, so the sender
|
// A present sender resolves to their live mesh thread via the
|
||||||
// resolves to the same DM thread whether or not they're present.
|
// derived short ID. An absent sender — the usual courier case —
|
||||||
let senderPeerID = PeerID(publicKey: senderStaticKey)
|
// 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())
|
let payload = Data(typedPayload.dropFirst())
|
||||||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
@@ -2757,6 +2762,9 @@ extension BLEService {
|
|||||||
// MARK: Private Message Handling
|
// MARK: Private Message Handling
|
||||||
|
|
||||||
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
|
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)
|
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
|
// Check if we have an established Noise session
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
func addPublicSystemMessage(_ content: String)
|
func addPublicSystemMessage(_ content: String)
|
||||||
|
|
||||||
// MARK: - Favorites
|
// 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 toggleFavorite(peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@@ -338,31 +339,28 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if add {
|
// Resolve current state by the peer's real noise key. The resolved
|
||||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||||
peerNoisePublicKey: noisePublicKey,
|
let isCurrentlyFavorite: Bool
|
||||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
if let noiseKey = peerID.noiseKey {
|
||||||
peerNickname: nickname
|
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||||
)
|
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
|
||||||
|
|
||||||
return .success(message: "added \(nickname) to favorites")
|
|
||||||
} else {
|
} else {
|
||||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
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)
|
contextProvider?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
|
||||||
|
|
||||||
return .success(message: "removed \(nickname) from favorites")
|
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
peerNostrPublicKey: String? = nil
|
peerNostrPublicKey: String? = nil
|
||||||
) {
|
) {
|
||||||
let existing = favorites[peerNoisePublicKey]
|
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)
|
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ struct CourierDirectory {
|
|||||||
static func favoritesBacked() -> CourierDirectory {
|
static func favoritesBacked() -> CourierDirectory {
|
||||||
CourierDirectory(
|
CourierDirectory(
|
||||||
noiseKey: { peerID in
|
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
|
isTrustedCourier: { noiseKey in
|
||||||
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
|
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)
|
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)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
enqueue(message, for: peerID)
|
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 {
|
} else {
|
||||||
var unsent = message
|
var unsent = message
|
||||||
unsent.sendAttempts = 0
|
unsent.sendAttempts = 0
|
||||||
@@ -133,11 +145,12 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last resort when no transport can reach the peer: seal the message to
|
/// Last resort when no transport can deliver promptly — the peer is
|
||||||
/// their known static key and hand it to connected mutual favorites who
|
/// unreachable, or only reachable through a send queue waiting on
|
||||||
/// may physically encounter them. The queued copy above stays retained,
|
/// internet: seal the message to their known static key and hand it to
|
||||||
/// so direct delivery still wins if the peer reappears first (receivers
|
/// connected mutual favorites who may physically encounter them. The
|
||||||
/// dedup by message ID).
|
/// 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) {
|
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
||||||
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
||||||
for transport in transports {
|
for transport in transports {
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> 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 {
|
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||||
Dependencies(
|
Dependencies(
|
||||||
notificationCenter: .default,
|
notificationCenter: .default,
|
||||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
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)
|
// Reachability Cache (thread-safe)
|
||||||
private var reachablePeers: Set<PeerID> = []
|
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)
|
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
queue.sync(flags: .barrier) {
|
queue.sync(flags: .barrier) {
|
||||||
self.reachablePeers = Set(reachable)
|
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 {
|
deinit {
|
||||||
@@ -125,15 +142,21 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
|
|
||||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
// Callers address peers by either the short 16-hex ID or the full
|
||||||
// Check if exact match
|
// 64-hex noise key (offline favorites), so compare in short form.
|
||||||
|
let short = peerID.toShort()
|
||||||
|
return queue.sync {
|
||||||
if reachablePeers.contains(peerID) { return true }
|
if reachablePeers.contains(peerID) { return true }
|
||||||
// Check for short ID match
|
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||||
if peerID.isShort {
|
|
||||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ protocol Transport: AnyObject {
|
|||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
func isPeerReachable(_ 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 peerNickname(peerID: PeerID) -> String?
|
||||||
func getPeerNicknames() -> [PeerID: String]
|
func getPeerNicknames() -> [PeerID: String]
|
||||||
|
|
||||||
@@ -111,6 +116,10 @@ protocol Transport: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
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 identity hooks default to inert for transports that do not carry
|
||||||
// Noise sessions (e.g. NostrTransport).
|
// Noise sessions (e.g. NostrTransport).
|
||||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<PeerID> = []
|
var connected: Set<PeerID> = []
|
||||||
var addedPeerIDs: Set<PeerID> = []
|
var addedPeerIDs: Set<PeerID> = []
|
||||||
|
var meshNoiseKeys: Set<Data> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
@@ -104,23 +105,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
if let publicKey = peerInfo.noisePublicKey {
|
if let publicKey = peerInfo.noisePublicKey {
|
||||||
|
meshNoiseKeys.insert(publicKey)
|
||||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
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 {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
|
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||||
|
|
||||||
let peerID = PeerID(hexData: favoriteKey)
|
let peerID = PeerID(hexData: favoriteKey)
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
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)
|
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||||
enrichedPeers.append(peer)
|
enrichedPeers.append(peer)
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|||||||
@@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
|
|||||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
func sendGeohashReadReceipt(_ 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.
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
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 {
|
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
|
@MainActor
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||||
guard let context else { return }
|
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 sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
func sendGeohashReadReceipt(_ 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
|
// MARK: System messages
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
|
|||||||
// MARK: Favorites & notifications
|
// MARK: Favorites & notifications
|
||||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
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).
|
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
||||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
||||||
/// Posts the incoming-private-message local notification.
|
/// Posts the incoming-private-message local notification.
|
||||||
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
|||||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
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?) {
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||||
peerNoisePublicKey: noiseKey,
|
peerNoisePublicKey: noiseKey,
|
||||||
@@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
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 isConnected = context.isPeerConnected(peerID)
|
||||||
let isReachable = context.isPeerReachable(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 isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||||
|
|
||||||
@@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
return
|
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 }
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||||
|
|
||||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sender: senderName,
|
sender: senderName,
|
||||||
@@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
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) {
|
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||||
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||||
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||||
@@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
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
|
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 isFavorite = content.hasPrefix("[FAVORITED]")
|
||||||
let parts = content.split(separator: ":")
|
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
|
@MainActor
|
||||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||||
|
|||||||
@@ -121,25 +121,6 @@ extension ChatViewModel {
|
|||||||
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
|
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
|
@MainActor
|
||||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||||
privateConversationCoordinator.handlePrivateMessage(message)
|
privateConversationCoordinator.handlePrivateMessage(message)
|
||||||
@@ -190,8 +171,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||||
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
|
privateConversationCoordinator.handleFavoriteNotification(
|
||||||
content,
|
content,
|
||||||
from: peerID,
|
from: peerID,
|
||||||
senderNickname: senderNickname
|
senderNickname: senderNickname
|
||||||
|
|||||||
@@ -442,8 +442,7 @@ final class NostrInboundPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
||||||
/// store. Lives here because the inbound DM path needs it per message;
|
/// store. Lives here because the inbound DM path needs it per message.
|
||||||
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
guard let context else { return nil }
|
guard let context else { return nil }
|
||||||
|
|||||||
@@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests {
|
|||||||
#expect(stamped > stale)
|
#expect(stamped > stale)
|
||||||
#expect(context.appendedGeohashMessages.count == 1)
|
#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
|
@Test @MainActor
|
||||||
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
|||||||
favoriteRelationshipsByNoiseKey[noiseKey]
|
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?) {
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||||
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
||||||
}
|
}
|
||||||
@@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
|
func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||||
let peerID = PeerID(hexData: noiseKey)
|
let peerID = PeerID(hexData: noiseKey)
|
||||||
|
|
||||||
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
|
// 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.count == 1)
|
||||||
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
||||||
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
||||||
@@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
noiseKey: noiseKey,
|
noiseKey: noiseKey,
|
||||||
theyFavoritedUs: true
|
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.peerFavoritedUsUpdates.count == 2)
|
||||||
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
||||||
|
|
||||||
// [UNFAVORITED] transition announces again.
|
// [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.peerFavoritedUsUpdates.last?.favorited == false)
|
||||||
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
|
#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
|
@Test @MainActor
|
||||||
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
@@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
#expect(context.systemMessages.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
||||||
let context = MockChatPrivateConversationContext()
|
let context = MockChatPrivateConversationContext()
|
||||||
|
|||||||
@@ -655,8 +655,10 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
|
||||||
|
/// out of the conversation transcript.
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
let identity = try NostrIdentity.generate()
|
let identity = try NostrIdentity.generate()
|
||||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||||
@@ -664,19 +666,33 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
FavoritesPersistenceService.shared.addFavorite(
|
FavoritesPersistenceService.shared.addFavorite(
|
||||||
peerNoisePublicKey: noiseKey,
|
peerNoisePublicKey: noiseKey,
|
||||||
peerNostrPublicKey: identity.npub,
|
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(
|
// The inbound pipeline resolves a known sender to their noise-key ID.
|
||||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
let convKey = PeerID(hexData: noiseKey)
|
||||||
from: identity.publicKeyHex
|
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)
|
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?.peerNostrPublicKey == identity.npub)
|
||||||
#expect(relationship?.isFavorite == true)
|
#expect(viewModel.privateChats[convKey, default: []].isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -303,6 +303,50 @@ struct CommandProcessorTests {
|
|||||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
#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
|
@MainActor
|
||||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||||
let identityManager = MockIdentityManager(MockKeychain())
|
let identityManager = MockIdentityManager(MockKeychain())
|
||||||
|
|||||||
@@ -170,8 +170,12 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let delivered = try #require(bobDelegate.snapshot().first)
|
let delivered = try #require(bobDelegate.snapshot().first)
|
||||||
#expect(delivered.type == .privateMessage)
|
#expect(delivered.type == .privateMessage)
|
||||||
// Sender resolves to Alice's stable mesh identity, not the courier's.
|
// Alice is absent from Bob's mesh, so the sender resolves to her
|
||||||
#expect(delivered.peerID == alice.myPeerID)
|
// 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))
|
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||||
#expect(message.messageID == "courier-msg-1")
|
#expect(message.messageID == "courier-msg-1")
|
||||||
#expect(message.content == "the camp moved north")
|
#expect(message.content == "the camp moved north")
|
||||||
@@ -540,7 +544,16 @@ private final class CourierCaptureTransport: Transport {
|
|||||||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||||
snapshots.contains { $0.peerID == peerID && $0.isConnected }
|
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 peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||||
|
|
||||||
@@ -617,6 +630,21 @@ struct MessageRouterCourierTests {
|
|||||||
#expect(carried.isEmpty)
|
#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
|
@Test @MainActor
|
||||||
func reachablePeerSkipsCourier() {
|
func reachablePeerSkipsCourier() {
|
||||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||||
@@ -633,4 +661,70 @@ struct MessageRouterCourierTests {
|
|||||||
#expect(transport.directSends == ["m3"])
|
#expect(transport.directSends == ["m3"])
|
||||||
#expect(transport.courierSends.isEmpty)
|
#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))
|
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() {
|
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||||
let peerKey = Data((96..<128).map(UInt8.init))
|
let peerKey = Data((96..<128).map(UInt8.init))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import BitFoundation
|
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(shortPeerID))
|
||||||
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
||||||
}
|
}
|
||||||
@@ -83,6 +86,51 @@ struct NostrTransportTests {
|
|||||||
#expect(didRefresh)
|
#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")
|
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||||
@@ -373,7 +421,8 @@ struct NostrTransportTests {
|
|||||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||||
sendEvent: @escaping @MainActor (NostrEvent) -> 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 {
|
||||||
NostrTransport.Dependencies(
|
NostrTransport.Dependencies(
|
||||||
notificationCenter: notificationCenter,
|
notificationCenter: notificationCenter,
|
||||||
@@ -383,7 +432,8 @@ struct NostrTransportTests {
|
|||||||
currentIdentity: currentIdentity,
|
currentIdentity: currentIdentity,
|
||||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||||
sendEvent: sendEvent,
|
sendEvent: sendEvent,
|
||||||
scheduleAfter: scheduleAfter
|
scheduleAfter: scheduleAfter,
|
||||||
|
relayConnectivity: relayConnectivity
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,117 @@ struct UnifiedPeerServiceTests {
|
|||||||
#expect(!service.isBlocked(peerID))
|
#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
|
@Test @MainActor
|
||||||
func setBlocked_unknownIdentityReturnsNil() async {
|
func setBlocked_unknownIdentityReturnsNil() async {
|
||||||
let transport = MockTransport()
|
let transport = MockTransport()
|
||||||
|
|||||||
Reference in New Issue
Block a user