diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 70a6c28c..8d444eec 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -18,6 +18,15 @@ final class AppChromeModel: ObservableObject { @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown @Published var showScreenshotPrivacyWarning = false + /// Latch for the people / conversation-list sheet. Owned here (rather than as + /// `ContentView` local `@State`) so non-view launch code can raise it: on launch + /// `AppRuntime` sets this to `true` when the last-active conversation resolves to + /// "present the conversation list" (first-ever launch or a stale/unrestorable DM + /// peer). `ContentView` binds the people sheet directly to this, and every + /// competing sheet/cover already gates on `!showSidebar`, so a single latch keeps + /// the launch presentation from colliding with the fingerprint / image-picker + /// sheets (#1064). + @Published var showSidebar = false private let chatViewModel: ChatViewModel private let onPanicWipe: () -> Void diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index b7c20511..0823647e 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -153,11 +153,223 @@ final class AppRuntime: ObservableObject { GeohashPresenceService.shared.start() checkForSharedContent() expireAgedMedia() + restoreLastActiveConversationOnLaunch() record(.launched) record(.startupCompleted) } + /// #1064: restore the last-active conversation at launch. A persisted DM + /// re-opens via the normal private-chat path (which never writes + /// `activeChannel`); a first-ever launch or a stale DM peer presents the + /// conversation list; a public channel defers to the existing mesh / + /// `GeoChannelCoordinator` restore (the sole launch-time writer of + /// `activeChannel`), so there is no race. + private func restoreLastActiveConversationOnLaunch() { + let presentation = conversations.restoreLastActiveConversation( + isPeerResolvable: { + Self.isDirectChatRestorable( + $0, + favorites: .shared, + hasStoredCryptographicIdentity: { + !chatViewModel.identityManager + .getCryptoIdentitiesByPeerIDPrefix($0.toShort()) + .isEmpty + }, + isPeerBlocked: { chatViewModel.isPeerBlocked($0) } + ) + } + ) + var didOpenDirectChat = false + if case .restoredDirectChat(let peerID) = presentation { + // `startPrivateChat`'s gate (ChatPeerIdentityCoordinator) rejects a + // now-blocked peer by emitting a system message and returning + // WITHOUT opening the chat. At launch that message would land in + // the current (public mesh) timeline, so pass + // `suppressSystemMessages: true` — the reject stays silent and we + // detect it via `selectedPrivateChatPeer`, which is only set on the + // success path. + // + // Not a second line of defence any more. Post-#1415 that gate + // screens only self, group and blocked, so it catches nothing + // `isDirectChatRestorable` has not already caught. It is kept for + // the narrow race where the peer is blocked between the predicate + // and this call, and for the silent-failure detection above. + chatViewModel.startPrivateChat(with: peerID, suppressSystemMessages: true) + didOpenDirectChat = chatViewModel.selectedPrivateChatPeer == peerID + } + // Fall back to the conversation list rather than silently landing on + // the public mesh timeline when a restore target existed but could not + // be opened. + if Self.shouldPresentConversationList(for: presentation, didOpenDirectChat: didOpenDirectChat) { + appChromeModel.showSidebar = true + } + } + + /// Whether a persisted last-active DM peer is genuinely restorable at + /// launch — validated against *durable* relationship state, never live + /// presence (mesh discovery is async, so no peer is connected yet). A + /// syntactically valid `PeerID` is NOT sufficient: an unknown peer would + /// otherwise fall straight through `startPrivateChat` into an empty phantom + /// DM. + /// + /// Restorable iff the peer is NOT blocked and we hold durable evidence + /// *locally* that the conversation is addressable: we favorited them, or we + /// have a stored cryptographic identity for them. + /// + /// `theyFavoritedUs` is deliberately NOT a term. It is remote state, and on + /// its own it proves nothing about our ability to address the peer — a peer + /// who favorited us, whom we never favorited and hold no identity for, is + /// exactly the unaddressable phantom this predicate exists to refuse. It + /// would also override a deliberate local unfavorite on the strength of the + /// other side's opinion. + /// + /// This tracks `ChatPeerIdentityCoordinator.startPrivateChat`, which #1415 + /// relaxed — it no longer requires a mutual favorite, on the grounds that + /// store-and-forward (couriers, bridge drops, retained outbox) needs only + /// the recipient's noise key, so "the router decides what delivery looks + /// like, not chat entry". Keeping the old mutual-favorite rule here would + /// have made launch-restore stricter than chat entry: a one-way-favorite or + /// merely-known peer whose DM is perfectly sendable would fail to restore + /// and drop the user on the conversation list instead. + /// + /// It cannot simply defer to that gate, though. Post-#1415 the open path + /// screens only self, group and blocked, so this predicate is the sole + /// defence against restoring into a phantom DM, and it has to hold the line + /// on its own. + /// + /// The terms are chosen for durability at launch. Favorites are + /// keychain-backed; stored cryptographic identities are on disk. The outbox + /// and live Noise session state are deliberately NOT consulted — the outbox + /// defers loading until protected data is available and session state is + /// in-memory, so both read empty at launch regardless of the truth. + /// + /// A private group is a virtual conversation, so none of the peer terms + /// apply to it and none of them would pass: a group id is `group_` plus 32 + /// hex, and both lookups guard on `isShort` (a 16-hex bare), so they return + /// empty for every group without ever consulting the group. Left to the + /// peer terms a group would therefore never restore — silently, every + /// time. `startPrivateChat` gates group re-entry on nothing at all ("no + /// peer identity, favorites, handshake … just select the chat"), so + /// admitting groups here restores what re-entry would have opened. (Not + /// quite an identity: the block veto above has no counterpart in + /// `startPrivateChat`'s group branch, so restore is the narrower of the + /// two. Moot today — `isBlocked` needs a resolved fingerprint and a group + /// id never has one — but stated rather than leaned on.) + /// + /// Note the bound on that admission: `isGroup` tests the `group_` prefix + /// only — `PeerID(str:)` assigns a prefix by `hasPrefix` and never + /// validates the bare — so this branch admits any persisted id *claiming* + /// to be a group, not a group proven to exist. + /// + /// That is acceptable, but for a narrower reason than "the id is trusted". + /// A group id can certainly originate remotely: an invite carries one, and + /// `GroupProtocol` derives the conversation id from it. Every such id is + /// built by `PeerID(groupID:)`, which hex-encodes the raw bytes, so a + /// group learned from the network still has a structurally valid bare. A + /// malformed `group_` id therefore implies corrupted local persistence + /// rather than hostile input. And the failure it produces is an empty + /// group, not a DM compose box aimed at an unreachable peer — which is the + /// specific hazard this predicate exists to prevent. Admitting only groups + /// that still exist locally would need a membership lookup this predicate + /// does not take. + /// + /// Geohash/Nostr ids are screened before the peer terms, so nothing can + /// bypass the check by matching earlier. A geoChat id is not a direct chat + /// at all. A geoDM's full Nostr key is rebuilt only from inbound ephemeral + /// events, so at launch it cannot resolve, and `startPrivateChat` skips the + /// handshake for geoDMs — a phantom would open with no error at all. Its + /// only conceivable durable anchor is our own favorite record. + /// + /// In practice that anchor does not exist yet, so **a geoDM never restores + /// today**. `FavoritesPersistenceService` is keyed by Noise public key + /// alone, and `getFavoriteStatus(forPeerID:)` matches by rebuilding + /// `PeerID(publicKey:)` — which carries no prefix — so it can never equal a + /// `nostr_`-prefixed id no matter what is favorited. The geoDM branch below + /// is kept because it is the right shape once a Nostr-keyed lookup exists, + /// but read it precisely: it resolves to `false` for the *production* + /// closure only. The injected seam will happily return `true` for a stub + /// that accepts a geoDM, so a passing test here is not evidence that geoDM + /// restore works. Documented rather than fixed: wiring the real lookup + /// means new favorites plumbing, which is not this change. + static func isDirectChatRestorable( + _ peerID: PeerID, + isPeerFavorited: (PeerID) -> Bool, + hasStoredCryptographicIdentity: (PeerID) -> Bool, + isPeerBlocked: (PeerID) -> Bool + ) -> Bool { + // Blocked is a veto, never one term among several — including over the + // group branch below, so no id class can sidestep it. + guard !isPeerBlocked(peerID) else { return false } + // A private group is local state, not a claim about reaching a peer. + // The peer terms below cannot represent it and would refuse it. + if peerID.isGroup { return true } + // A geohash channel is not a direct chat. + guard !peerID.isGeoChat else { return false } + // Screened before the identity term rather than after, so no earlier + // match can skip it. The identity lookup does not reject these on its + // own merits either: a `nostr_` id satisfies `isShort` (the prefix is + // not part of the length check), and today it misses only because the + // lookup re-attaches that prefix and no hex fingerprint starts with it. + // Luck, not a rule. + if peerID.isGeoDM { return isPeerFavorited(peerID) } + return isPeerFavorited(peerID) || hasStoredCryptographicIdentity(peerID) + } + + /// Production wiring of `isDirectChatRestorable`, extracted so the real + /// favorites/block lookups (not just stub predicates) are unit-testable via + /// an injected in-memory-keychain-backed `FavoritesPersistenceService` and a + /// block closure. `migrateSelectedConversationIfNeeded` can persist the + /// last-active peer in full 64-hex Noise-key form, but the favorites store is + /// keyed by the short, Noise-key-derived id — so normalize with `toShort()` + /// (a no-op on an already-short id) before the lookup, or favorited DMs + /// silently fail to restore. The block lookup mirrors the open-path gate's + /// `unifiedIsBlocked` (fingerprint-resolved, so it works for offline + /// favorites). + /// + /// The identity lookup is passed in rather than reached for: it lives on + /// the injected `SecureIdentityStateManagerProtocol`, so tests stub it the + /// same way they stub the favorites service instead of sharing + /// process-wide state. It needs the same `toShort()` normalization — + /// `getCryptoIdentitiesByPeerIDPrefix` guards on `isShort` and returns an + /// empty result otherwise, so a 64-hex id would read as "no identity" + /// rather than as an error. Synchronous and disk-backed, so it is safe on + /// the launch path. + static func isDirectChatRestorable( + _ peerID: PeerID, + favorites: FavoritesPersistenceService, + hasStoredCryptographicIdentity: (PeerID) -> Bool, + isPeerBlocked: (PeerID) -> Bool + ) -> Bool { + isDirectChatRestorable( + peerID, + isPeerFavorited: { + favorites.getFavoriteStatus(forPeerID: $0.toShort())?.isFavorite ?? false + }, + hasStoredCryptographicIdentity: hasStoredCryptographicIdentity, + isPeerBlocked: isPeerBlocked + ) + } + + /// Pure launch-effect decision, extracted so the fallback is unit-testable + /// without constructing `AppRuntime`: present the conversation list on a + /// first-ever launch, or when a persisted DM could not actually be opened + /// (blocked / stale / gated peer). A public-channel restore is left to + /// `GeoChannelCoordinator`. + static func shouldPresentConversationList( + for presentation: ConversationStore.LaunchPresentation, + didOpenDirectChat: Bool + ) -> Bool { + switch presentation { + case .conversationList: + return true + case .restoredDirectChat: + return !didOpenDirectChat + case .deferToChannelRestore: + return false + } + } + /// Drops media that has outlived the retention window. Off the main thread /// and best-effort: the sweep walks the media tree, and nothing at launch /// depends on its result. diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index eb174120..87b359fd 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -372,6 +372,129 @@ final class ConversationStore: ObservableObject { let changes = PassthroughSubject() + // MARK: Last-active persistence (#1064) + + /// Persisted last-active conversation, so launch can restore where the + /// user left off instead of always dropping into the public mesh + /// timeline. Mirrors `LocationStateManager`'s `locationChannel.selected` + /// idiom: injected `UserDefaults`, JSON-encoded value. Location channels + /// are deliberately NOT restored here — `GeoChannelCoordinator` already + /// owns launch-time channel restore; persisting a `.location` marker only + /// lets us tell "was in a channel" apart from a first-ever launch. + private let storage: UserDefaults + private let lastActiveKey = "conversation.lastActive" + + /// True for the duration of a panic wipe. Suppresses `persistLastActive()` + /// so the selection/channel resets the wipe performs cannot re-write the + /// pointer we are about to remove. + private var isPanicWiping = false + + /// Snapshot of the persisted value read once at init, before any launch + /// writer (e.g. `GeoChannelCoordinator` re-applying the location channel) + /// can overwrite the key. Restore decisions read this, never the disk. + private let restoredLastActive: LastActiveRecord? + + /// Codable form of the last-active conversation. `peerID` is a peer's + /// `PeerID.id` string (`PeerID` is not itself `Codable`). + private struct LastActiveRecord: Codable, Equatable { + enum Kind: String, Codable { case mesh, location, direct } + let kind: Kind + let peerID: String? + } + + /// What launch should present, decided purely from `restoredLastActive`. + enum LaunchPresentation: Equatable { + /// First-ever launch, or a persisted DM whose peer no longer resolves. + case conversationList + /// A valid persisted DM — the caller re-opens it via the normal + /// private-chat path (this axis never writes `activeChannel`). + case restoredDirectChat(PeerID) + /// Last-active was a public channel — defer to the existing mesh / + /// `GeoChannelCoordinator` restore; do nothing here. + case deferToChannelRestore + } + + /// Default persistence store for the last-active conversation. Production + /// uses `.standard`. Under test, a dedicated scratch suite is used instead + /// — wiped at first use per process — so the ~48 no-arg `ConversationStore()` + /// tests never pollute the real app's `.standard` `conversation.lastActive` + /// and back-to-back local runs never see each other's persisted selection. + /// Mirrors `ChatViewModel.defaultReadReceiptsDefaults`. + static let defaultStorage: UserDefaults = { + guard TestEnvironment.isRunningTests else { return .standard } + let suiteName = "chat.bitchat.tests.conversationStore" + guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } + scratch.removePersistentDomain(forName: suiteName) + return scratch + }() + + init(storage: UserDefaults = ConversationStore.defaultStorage) { + self.storage = storage + if let data = storage.data(forKey: lastActiveKey), + let record = try? JSONDecoder().decode(LastActiveRecord.self, from: data) { + self.restoredLastActive = record + } else { + self.restoredLastActive = nil + } + } + + /// Persists the current foreground conversation. Called from the two + /// single-writer selection paths on every switch; an open DM wins over + /// the active channel. + private func persistLastActive() { + // Panic wipe suppresses last-active persistence so the selection/channel + // resets that follow it can't re-write the pointer being cleared. + guard !isPanicWiping else { return } + let record: LastActiveRecord + if let peerID = selectedPrivatePeerID { + record = LastActiveRecord(kind: .direct, peerID: peerID.id) + } else if activeChannel.isLocation { + record = LastActiveRecord(kind: .location, peerID: nil) + } else { + record = LastActiveRecord(kind: .mesh, peerID: nil) + } + if let data = try? JSONEncoder().encode(record) { + storage.set(data, forKey: lastActiveKey) + } + } + + /// Erases the persisted last-active conversation. Called from the panic + /// wipe so a restored DM/channel pointer cannot survive an emergency clear. + /// The store owns its injected `storage`, so the key is removed through it + /// (never by reaching into `.standard`), and `lastActiveKey` stays private. + func clearPersistedLastActive() { + storage.removeObject(forKey: lastActiveKey) + } + + /// Begins a panic wipe: suppress last-active persistence so the selection/channel + /// resets that follow cannot re-write the pointer we're about to remove. + func beginPanicWipe() { isPanicWiping = true } + + /// Finishes a panic wipe: remove the persisted pointer and re-enable persistence. + /// MUST be called AFTER all selection/channel state has been reset. + func finishPanicWipe() { + clearPersistedLastActive() + isPanicWiping = false + } + + /// Decides what to present at launch from the value persisted last + /// session. Pure aside from reading the init snapshot: performs no + /// selection mutation and never writes `activeChannel`, so it cannot race + /// the location-channel restore. `isPeerResolvable` lets the caller reject + /// a stale/unaddressable DM peer, falling back to the conversation list. + func restoreLastActiveConversation(isPeerResolvable: (PeerID) -> Bool) -> LaunchPresentation { + guard let record = restoredLastActive else { return .conversationList } + switch record.kind { + case .mesh, .location: + return .deferToChannelRestore + case .direct: + guard let raw = record.peerID, !raw.isEmpty else { return .conversationList } + let peerID = PeerID(str: raw) + guard isPeerResolvable(peerID) else { return .conversationList } + return .restoredDirectChat(peerID) + } + } + // MARK: Intent API /// Returns the conversation for `id`, creating it (with the cap policy @@ -553,6 +676,11 @@ final class ConversationStore: ObservableObject { func setActiveChannel(_ channelID: ChannelID) { if activeChannel != channelID { activeChannel = channelID + // Persist only on an ACTUAL change. A redundant re-apply of the + // current channel (e.g. `GeoChannelCoordinator` re-asserting the + // default mesh channel at launch) must not overwrite a persisted + // `.direct` record when a DM restore has just failed (#1064). + persistLastActive() } refreshDerivedSelection() } @@ -562,6 +690,9 @@ final class ConversationStore: ObservableObject { func setSelectedPrivatePeer(_ peerID: PeerID?) { if selectedPrivatePeerID != peerID { selectedPrivatePeerID = peerID + // Persist only on an actual selection change; the people-sheet + // dismissal re-invokes this with the same (nil) value. + persistLastActive() } refreshDerivedSelection() } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f34ed351..2d2759a3 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -37912,6 +37912,378 @@ } } }, + "content.header.public_caption" : { + "comment" : "Trust caption under the public mesh timeline: this channel is public and reaches nearby devices. Reuses the composer placeholder vocabulary (content.input.placeholder.mesh).", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عام · قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পাবলিক · কাছাকাছি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffentlich · in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "public · nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "público · cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "عمومی · اطراف شما" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pampubliko · malapit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "public · à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ציבורי · קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सार्वजनिक · आसपास" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "publik · terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "pubblico · nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "公開 · 近くの人へ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공개 · 근처" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "awam · berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सार्वजनिक · नजिकको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "openbaar · in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "publiczna · w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "público · próximo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "público · por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "публичное · рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "publikt · i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பொது · அருகில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "สาธารณะ · ใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "herkese açık · yakında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "публічне · поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "عوامی · قریبی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "công khai · gần đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公开 · 附近" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公開 · 附近" + } + } + } + }, + "content.header.public_caption.a11y" : { + "comment" : "Accessibility label for the public mesh trust caption; comma form of content.header.public_caption for VoiceOver.", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عام، قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পাবলিক, কাছাকাছি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffentlich, in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "public, nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "público, cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "عمومی، اطراف شما" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pampubliko, malapit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "public, à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ציבורי, קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सार्वजनिक, आसपास" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "publik, terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "pubblico, nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "公開、近くの人へ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공개, 근처" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "awam, berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सार्वजनिक, नजिकको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "openbaar, in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "publiczna, w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "público, próximo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "público, por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "публичное, рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "publikt, i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பொது, அருகில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "สาธารณะ, ใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "herkese açık, yakında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "публічне, поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "عوامی، قریبی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "công khai, gần đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公开、附近" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公開、附近" + } + } + } + }, "content.help.verification" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 93b0f32a..d1c6accc 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -319,7 +319,7 @@ final class ChatPeerIdentityCoordinator { } @MainActor - func startPrivateChat(with peerID: PeerID) { + func startPrivateChat(with peerID: PeerID, suppressSystemMessages: Bool = false) { guard peerID != context.myPeerID else { return } // Group chats are virtual conversations: no peer identity, favorites, @@ -334,16 +334,18 @@ final class ChatPeerIdentityCoordinator { let peerNickname = context.peerNickname(for: peerID) ?? "unknown" if context.unifiedIsBlocked(peerID) { - context.addSystemMessage( - String( - format: String( - localized: "system.chat.blocked", - comment: "System message when starting chat fails because peer is blocked" - ), - locale: .current, - peerNickname + if !suppressSystemMessages { + context.addSystemMessage( + String( + format: String( + localized: "system.chat.blocked", + comment: "System message when starting chat fails because peer is blocked" + ), + locale: .current, + peerNickname + ) ) - ) + } return } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d794ea3f..500a3837 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1492,12 +1492,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage /// Initiates a private chat session with a peer. /// - Parameter peerID: The peer's ID to start chatting with - /// - Note: Switches the UI to private chat mode and loads message history + /// - Note: Switches the UI to private chat mode and loads message history. + /// This zero-extra-argument signature is the witness for the + /// `CommandProcessor` / `ChatNostrCoordinator` context protocols, so it is + /// kept intact; the launch-restore variant is a separate overload below. @MainActor func startPrivateChat(with peerID: PeerID) { peerIdentityCoordinator.startPrivateChat(with: peerID) } + /// #1064 launch-restore variant of `startPrivateChat`. When + /// `suppressSystemMessages` is `true`, a gate rejection no-ops silently + /// instead of emitting a system message into the current (public mesh) + /// timeline, so a rejected DM restore falls back to the conversation list + /// cleanly. Since #1415 removed the mutual-favorite gate that is exactly + /// one message — the blocked one. Kept as a distinct non-defaulted + /// overload — a defaulted extra parameter would not satisfy the context + /// protocols above and a default would make the plain call ambiguous. + @MainActor + func startPrivateChat(with peerID: PeerID, suppressSystemMessages: Bool) { + peerIdentityCoordinator.startPrivateChat(with: peerID, suppressSystemMessages: suppressSystemMessages) + } + @MainActor func endPrivateChat() { peerIdentityCoordinator.endPrivateChat() @@ -1586,6 +1602,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // single-writer ConversationStore; the derived `messages` view and // the legacy mirror empty with it) conversations.clearAll() + // Begin suppressing last-active persistence (#1064). The selection and + // channel resets below route through the store's setters, which would + // otherwise re-persist a `.mesh` pointer. + // + // Finished via `defer` rather than a call at the end: this method now + // has an early `return false` when the keychain wipe is incomplete, and + // suppression that outlives the wipe would silently stop persisting the + // last-active conversation for the rest of the process. `defer` also + // keeps the ordering the store requires — it runs after every selection + // and channel reset below, so no setter can re-persist the pointer + // afterwards, and the pointer is removed exactly once, leaving the key + // absent so the next launch hits the conversation-list fallback. + conversations.beginPanicWipe() + defer { conversations.finishPanicWipe() } pendingGeohashSystemMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 4efb0d34..a5ccbc40 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -99,7 +99,8 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.appTheme) private var appTheme @Environment(\.scenePhase) private var scenePhase - @State private var showSidebar = false + // `showSidebar` (the people/conversation-list sheet latch) lives on + // `AppChromeModel` so non-view launch code can raise it; see that property. @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @FocusState private var isNicknameFieldFocused: Bool @@ -137,7 +138,7 @@ struct ContentView: View { private var usesGlassLayout: Bool { appTheme.usesGlassChrome } private var isPeopleSheetPresented: Bool { - showSidebar || selectedPrivatePeerID != nil + appChromeModel.showSidebar || selectedPrivatePeerID != nil } private func rootModalPresentationState( @@ -252,7 +253,7 @@ struct ContentView: View { #endif .onChange(of: selectedPrivatePeerID) { newValue in if newValue != nil { - showSidebar = true + appChromeModel.showSidebar = true } sharedContentImportModel.updateDestination(sharedContentDestination) } @@ -264,7 +265,7 @@ struct ContentView: View { get: { isPeopleSheetPresented }, set: { isPresented in if !isPresented { - showSidebar = false + appChromeModel.showSidebar = false // Scene/background and alert-presentation // reconciliation (Bluetooth-off, recording errors) // are not user requests to leave the conversation. @@ -281,7 +282,7 @@ struct ContentView: View { ) { #if os(iOS) ContentPeopleSheetView( - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, messageText: $messageText, selectedMessageSender: $selectedMessageSender, selectedMessageSenderID: $selectedMessageSenderID, @@ -299,7 +300,7 @@ struct ContentView: View { ) #else ContentPeopleSheetView( - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, messageText: $messageText, selectedMessageSender: $selectedMessageSender, selectedMessageSenderID: $selectedMessageSenderID, @@ -324,7 +325,7 @@ struct ContentView: View { .environmentObject(locationChannelsModel) } .sheet(isPresented: Binding( - get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, + get: { appChromeModel.showingFingerprintFor != nil && !appChromeModel.showSidebar && selectedPrivatePeerID == nil }, set: { _ in appChromeModel.clearFingerprint() } )) { if let peerID = appChromeModel.showingFingerprintFor { @@ -334,7 +335,7 @@ struct ContentView: View { } #if os(iOS) .fullScreenCover(isPresented: Binding( - get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil }, + get: { showImagePicker && !appChromeModel.showSidebar && selectedPrivatePeerID == nil }, set: { newValue in if !newValue { showImagePicker = false @@ -350,7 +351,7 @@ struct ContentView: View { #endif #if os(macOS) .sheet(isPresented: Binding( - get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil }, + get: { showMacImagePicker && !appChromeModel.showSidebar && selectedPrivatePeerID == nil }, set: { newValue in if !newValue { showMacImagePicker = false @@ -439,7 +440,10 @@ struct ContentView: View { } .safeAreaInset(edge: .bottom, spacing: 0) { if selectedPrivatePeerID == nil { - composerView + VStack(spacing: 0) { + meshPrivacyCaption + composerView + } } } } else { @@ -460,15 +464,35 @@ struct ContentView: View { Divider() if selectedPrivatePeerID == nil { + meshPrivacyCaption composerView } } } } + /// Persistent trust caption under the PUBLIC mesh timeline — the parity + /// twin of the DM sheet's `privacyCaption` (#1366). Moved here out of the + /// header's non-compressible trailing cluster, where its `.fixedSize` text + /// overflowed narrow (SE-width) headers. Mesh-only: geohash/location + /// channels carry no such caption. Muted rather than orange — orange is the + /// DM privacy signal; this surface is deliberately public. + @ViewBuilder + private var meshPrivacyCaption: some View { + if case .mesh = locationChannelsModel.selectedChannel { + Text("content.header.public_caption") + .bitchatFont(size: 11, weight: .medium) + .foregroundColor(palette.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .themedSurface() + .accessibilityLabel(Text("content.header.public_caption.a11y")) + } + } + private var headerView: some View { ContentHeaderView( - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, showVerifySheet: $showVerifySheet, isNicknameFieldFocused: $isNicknameFieldFocused, headerHeight: headerHeight, @@ -487,7 +511,7 @@ struct ContentView: View { imagePreviewURL: $imagePreviewURL, windowCountPublic: $windowCountPublic, windowCountPrivate: $windowCountPrivate, - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, isTextFieldFocused: $isTextFieldFocused ) } diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index bbdda609..675aa1f7 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -303,6 +303,48 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.markedReadPeers.isEmpty) } + @Test @MainActor + func startPrivateChat_suppressed_blockedPeerEmitsNoSystemMessage() async { + // #1064: at launch a now-blocked (e.g. favorited-then-blocked) peer must + // reject SILENTLY — no "blocked" line leaked into the public timeline — + // while still not opening the chat, so the caller falls back to the list. + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.blockedPeers = [peerID] + + coordinator.startPrivateChat(with: peerID, suppressSystemMessages: true) + + #expect(context.systemMessages.isEmpty) + #expect(context.begunChatSessions.isEmpty) + #expect(context.consolidatedPeers.isEmpty) + #expect(context.markedReadPeers.isEmpty) + } + + @Test @MainActor + func startPrivateChat_suppressed_oneWayFavoriteOpensChatSilently() async { + // #1064 × #1415: the mutual-favorite gate is gone (store-and-forward + // handles offline non-mutual favorites), so a one-way favorite now + // opens the chat — and under suppression still emits no system message. + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data((0..<32).map(UInt8.init)) + var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + peer.favoriteStatus = makeFavoriteRelationship( + noiseKey: noiseKey, + isFavorite: true, + theyFavoritedUs: false + ) + context.peersByID[peerID] = peer + + coordinator.startPrivateChat(with: peerID, suppressSystemMessages: true) + + #expect(context.systemMessages.isEmpty) + #expect(context.begunChatSessions == [peerID]) + #expect(context.consolidatedPeers.map(\.peerID) == [peerID]) + } + @Test @MainActor func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async { let context = MockChatPeerIdentityContext() diff --git a/bitchatTests/ConversationStoreLastActiveTests.swift b/bitchatTests/ConversationStoreLastActiveTests.swift new file mode 100644 index 00000000..f7fc07b3 --- /dev/null +++ b/bitchatTests/ConversationStoreLastActiveTests.swift @@ -0,0 +1,610 @@ +// +// ConversationStoreLastActiveTests.swift +// bitchatTests +// +// Tests for #1064 last-active persistence: ConversationStore records the +// foreground conversation on every switch and, at the next launch, decides +// what to present — a valid DM restores, a stale DM or a first-ever launch +// falls back to the conversation list, and a public channel defers to the +// existing GeoChannelCoordinator restore. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import XCTest +@testable import bitchat + +@MainActor +final class ConversationStoreLastActiveTests: XCTestCase { + /// A structurally valid short (16-hex) peer id. + private let peerID = PeerID(str: "0123456789abcdef") + + func test_persistsLastActiveOnEverySwitch() { + let storage = makeStorage() + + // Open a DM: the next launch should restore it. + let session1 = ConversationStore(storage: storage) + session1.setSelectedPrivatePeer(peerID) + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .restoredDirectChat(peerID) + ) + + // Close the DM back to the mesh channel: the write must happen again, + // so the next launch now defers to the channel restore, not the DM. + session1.setSelectedPrivatePeer(nil) + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .deferToChannelRestore + ) + } + + func test_restoresValidDirectChat() { + let storage = makeStorage() + ConversationStore(storage: storage).setSelectedPrivatePeer(peerID) + + let restored = ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { $0.isValid }) + + XCTAssertEqual(restored, .restoredDirectChat(peerID)) + } + + func test_staleDirectChatFallsBackToConversationList() { + let storage = makeStorage() + ConversationStore(storage: storage).setSelectedPrivatePeer(peerID) + + // The persisted peer no longer resolves at launch. + let restored = ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in false }) + + XCTAssertEqual(restored, .conversationList) + } + + func test_failedDirectRestoreDoesNotEraseDirectRecord() { + // #1064: a redundant channel re-apply must not clobber a persisted DM. + let storage = makeStorage() + + // Session 1: the user is in a DM — persists a `.direct` record. + ConversationStore(storage: storage).setSelectedPrivatePeer(peerID) + + // Session 2 (launch): a fresh store starts with no private peer + // selected. The DM restore has just failed, and GeoChannelCoordinator + // re-asserts the SAME (default mesh) active channel. Because the channel + // does not actually change, the persist is now guarded out — so the + // `.direct` record on disk must survive rather than be overwritten with + // `.mesh`. + let launch = ConversationStore(storage: storage) + launch.setActiveChannel(launch.activeChannel) + + // Session 3: the DM record is intact and still restores. + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .restoredDirectChat(peerID) + ) + } + + func test_clearPersistedLastActiveErasesRestoreRecord() { + // #1064 panic wipe: clearing the persisted last-active pointer means a + // wiped DM/channel cannot be restored on the next launch. + let storage = makeStorage() + + // A DM is persisted and would otherwise restore. + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(peerID) + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .restoredDirectChat(peerID) + ) + + // The panic path erases the pointer through the store's own storage. + session.clearPersistedLastActive() + + // The next launch has nothing to restore and falls back to the list. + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .conversationList + ) + } + + func test_panicWipeOrderLeavesNoRestoreRecord() { + // #1064 panic wipe, FULL ORDER: `clearPersistedLastActive()` alone is + // insufficient because `panicClearAllData()` also runs + // `selectedPrivateChatPeer = nil` and `activeChannel = .mesh` AFTER it — + // both route through the store's setters, whose guarded `persistLastActive()` + // re-writes a `.mesh` record on a real state change, resurrecting the + // pointer. The begin/finish suppression window fixes that. This test + // mirrors panicClearAllData's exact order against a single store: + // beginPanicWipe → setSelectedPrivatePeer(nil) → setActiveChannel(.mesh) → finishPanicWipe + // Without the fix (i.e. without begin/finish), those selection/channel + // resets re-persist a `.mesh` record — here `setSelectedPrivatePeer(nil)` + // is the real state change that writes it (the store's default channel + // is already `.mesh`, so `setActiveChannel(.mesh)` is the second trigger + // whenever panic runs from a non-mesh channel). Either way the pointer + // survives as `.mesh` and the next launch would `.deferToChannelRestore` + // instead of `.conversationList`. + let storage = makeStorage() + + // The user is in a DM: a `.direct` record is persisted and would restore. + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(peerID) + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .restoredDirectChat(peerID) + ) + + // Reproduce panicClearAllData's order on the SAME store. + session.beginPanicWipe() + session.setSelectedPrivatePeer(nil) // re-persist trigger #1 (suppressed) + session.setActiveChannel(.mesh) // re-persist trigger #2 (suppressed) + session.finishPanicWipe() // removes the pointer once + + // A fresh store on the SAME storage finds NO record and falls back to + // the conversation list — the pointer is truly absent, not `.mesh`. + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .conversationList + ) + } + + func test_firstLaunchPresentsConversationList() { + let storage = makeStorage() + + let restored = ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }) + + XCTAssertEqual(restored, .conversationList) + } + + // MARK: - Launch effect (silent-mesh fallback) + + func test_launchPresentsList_whenRestoredDirectChatDidNotOpen() { + // A persisted DM whose peer is now blocked/stale/gated: startPrivateChat + // no-ops, so no chat opens — must fall back to the conversation list, + // never silently land on the public mesh timeline. + XCTAssertTrue( + AppRuntime.shouldPresentConversationList( + for: .restoredDirectChat(peerID), + didOpenDirectChat: false + ) + ) + } + + func test_launchDoesNotPresentList_whenRestoredDirectChatOpened() { + XCTAssertFalse( + AppRuntime.shouldPresentConversationList( + for: .restoredDirectChat(peerID), + didOpenDirectChat: true + ) + ) + } + + func test_launchDefersToChannelRestore_withoutPresentingList() { + // A public-channel restore is owned by GeoChannelCoordinator; the + // launch decision must not present the list on top of it. + XCTAssertFalse( + AppRuntime.shouldPresentConversationList( + for: .deferToChannelRestore, + didOpenDirectChat: false + ) + ) + } + + // MARK: - Restorability predicate (durable state, not syntax) + + func test_unknownButSyntacticallyValidPeerIsNotRestorable() { + // Regression guard: a well-formed 16-hex peer id we have NO durable + // relationship with must NOT be treated as restorable. Otherwise it + // falls straight through startPrivateChat into an empty phantom DM. + // (The syntax-only `{ $0.isValid }` resolver missed exactly this.) + XCTAssertTrue(peerID.isValid) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_mutualFavoritePeerIsRestorable() { + // A persisted MUTUAL favorite is stored by stable Noise public key and + // survives restart — the one durable, presence-independent mesh + // relationship. Mirrors the open-path gate, which only lets an offline + // favorite through when the favorite is mutual. + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in true }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_oneWayFavoritePeerIsRestorable() { + // INVERTED by #1415, deliberately kept as the record of that change. + // This used to be false: the open path required a mutual favorite, so + // restoring a one-way favorite would have injected a "requires + // favorite" system message into the public timeline. #1415 removed that + // gate — store-and-forward needs only the recipient's noise key — so a + // one-way favorite is now perfectly sendable, and refusing to restore + // it would make launch stricter than chat entry. + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in true }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_peerWhoOnlyFavoritedUsIsNotRestorable() { + // Remote state alone is not evidence we can address them. They favorited + // us, we never favorited them, and we hold no stored identity — the + // unaddressable phantom this predicate exists to refuse. Their opinion + // of us says nothing about whether we have a key for them. + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_geoChatIdIsNeverRestorableAsADirectChat() { + // A geohash channel id is not a direct chat at all, and the screen runs + // before the favorite term so nothing can match past it. + let geoChatID = PeerID(str: "nostr:01234567") + XCTAssertTrue(geoChatID.isGeoChat) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + geoChatID, + isPeerFavorited: { _ in true }, + hasStoredCryptographicIdentity: { _ in true }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_establishedNonFavoritePeerIsRestorable() { + // No favorite either way, but we hold a stored cryptographic identity — + // durable, on disk, and enough for the router to deliver via courier or + // the retained outbox. Restoring it is not a phantom. + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in true }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_blockedEstablishedPeerIsNotRestorable() { + // Blocked is a veto, not one term among several: a stored identity must + // not buy its way past the block the way it passes the favorite terms. + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in true }, + isPeerBlocked: { _ in true } + ) + ) + } + + func test_geoDMWithStoredIdentityIsNotRestorable() { + // The identity term must NOT extend to geohash DMs. A `nostr_` id's + // full Nostr key is rebuilt only from inbound ephemeral events, and + // startPrivateChat skips the handshake for geoDMs, so a phantom would + // open with no error at all. Today the lookup would miss anyway (the + // id's prefix is re-attached and no hex fingerprint starts with it), + // which is luck — this pins the refusal so a change to that lookup + // cannot quietly turn geoDM phantoms back on. + let geoDMPeer = PeerID(str: "nostr_0123456789abcdef") + XCTAssertTrue(geoDMPeer.isGeoDM) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + geoDMPeer, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in true }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_geoDMWithFavoriteIsRestorable() { + // A favorite record IS a geoDM's durable anchor (it carries + // peerNostrPublicKey), so the favorite terms still admit one. + let geoDMPeer = PeerID(str: "nostr_0123456789abcdef") + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + geoDMPeer, + isPeerFavorited: { _ in true }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_privateGroupIsRestorable() { + // A group id is `group_` plus 32 hex, so both peer lookups guard on + // `isShort` and return empty for it. Left to those terms a group would + // never restore, silently, every time — even though `startPrivateChat` + // gates group re-entry on nothing at all. + let group = PeerID(str: "group_" + String(repeating: "ab", count: 16)) + XCTAssertTrue(group.isGroup, "test fixture is not a group id") + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + group, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_privateGroupNeverConsultsThePeerTerms() { + // Admitting groups by accident — because some peer term happened to + // match — would be a different bug wearing the same green check. The + // group must be admitted on its own branch, before either lookup runs. + let group = PeerID(str: "group_" + String(repeating: "cd", count: 16)) + var favoriteLookups = 0 + var identityLookups = 0 + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + group, + isPeerFavorited: { _ in favoriteLookups += 1; return false }, + hasStoredCryptographicIdentity: { _ in identityLookups += 1; return false }, + isPeerBlocked: { _ in false } + ) + ) + XCTAssertEqual(favoriteLookups, 0, "group restore consulted the favorites term") + XCTAssertEqual(identityLookups, 0, "group restore consulted the identity term") + } + + func test_idClassesAreMutuallyExclusive() { + // The group branch sits between the geoChat guard and the geoDM one, + // so its placement would be load-bearing if an id could belong to two + // classes at once. `PeerID` assigns exactly one prefix, so it cannot — + // pin that, because the day it stops being true the ordering silently + // decides which rule wins. + let group = PeerID(str: "group_" + String(repeating: "ab", count: 16)) + let geoDM = PeerID(str: "nostr_0123456789abcdef") + let geoChat = PeerID(str: "nostr:someGeohashChannel") + + XCTAssertTrue(group.isGroup) + XCTAssertFalse(group.isGeoDM) + XCTAssertFalse(group.isGeoChat) + + XCTAssertFalse(geoDM.isGroup) + XCTAssertFalse(geoChat.isGroup) + } + + func test_malformedGroupIDIsStillAdmitted_documentingTheBound() { + // `isGroup` tests the prefix only — `PeerID(str:)` never validates the + // bare — so this branch admits any persisted id claiming to be a + // group. That is deliberate and bounded: the value is written by our + // own selection path into local state, never parsed from the network, + // and the failure it can produce is an empty group rather than the + // phantom DM this predicate exists to prevent. Pinned so that if the + // id ever becomes untrusted, this test is the thing that has to change. + let malformed = PeerID(str: "group_not-hex") + XCTAssertTrue(malformed.isGroup) + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + malformed, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_blockedGroupIsNotRestorable() { + // Block stays an unconditional veto, ahead of the group branch. + let group = PeerID(str: "group_" + String(repeating: "ef", count: 16)) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + group, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in true } + ) + ) + } + + func test_blockedMutualFavoriteIsNotRestorable() { + // A blocked peer is never restorable, even if the favorite is mutual — + // mirrors the gate's first (block) reject. + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + peerID, + isPeerFavorited: { _ in true }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in true } + ) + ) + } + + func test_geoDMPeerWithoutMutualFavoriteIsNotRestorable() { + // #1064 phantom-DM fix: a geohash/Nostr DM id is NO LONGER special-cased + // as restorable. Its full Nostr key is rebuilt only from inbound + // ephemeral events, so at launch a restored `nostr_` id cannot resolve + // and would open an unsendable phantom. Only a mutual favorite restores. + let geoDMPeer = PeerID(str: "nostr_0123456789abcdef") + XCTAssertTrue(geoDMPeer.isGeoDM) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + geoDMPeer, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_restoreWithProductionShapedResolver_unknownPeerYieldsConversationList() { + // End-to-end through ConversationStore using the PRODUCTION predicate + // shape (not a bare `{ _ in false }`): a persisted DM whose peer is + // unknown/unfavorited must present the conversation list, never restore + // a phantom DM. This is the test that would have caught the hole. + let storage = makeStorage() + ConversationStore(storage: storage).setSelectedPrivatePeer(peerID) + + let restored = ConversationStore(storage: storage).restoreLastActiveConversation( + isPeerResolvable: { + AppRuntime.isDirectChatRestorable( + $0, + isPeerFavorited: { _ in false }, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + } + ) + + XCTAssertEqual(restored, .conversationList) + } + + // MARK: - Production wiring (real FavoritesPersistenceService) + + func test_production_fullHexMutualFavoritePeerIsRestorable() { + // migrateSelectedConversationIfNeeded persists the peer in FULL 64-hex + // Noise-key form; the favorites store is keyed by the short derived id. + // The production resolver must normalize (`toShort()`) so a favorited DM + // still restores. Regression for fix-round-3 finding 1. The favorite must + // be MUTUAL to mirror the open-path gate. + let favorites = FavoritesPersistenceService(keychain: MockKeychain()) + let noiseKey = Data((0..<32).map(UInt8.init)) + favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice") + favorites.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + + let fullHexPeer = PeerID(str: noiseKey.hexEncodedString()) + XCTAssertFalse(fullHexPeer.isShort) // 64-hex, not the short form + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + fullHexPeer, + favorites: favorites, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_production_oneWayFavoriteIsRestorable() { + // INVERTED by #1415, through the real favorites wiring. This used to + // refuse a non-mutual favorite because the open-path gate would have + // rejected it at launch; that gate is gone, so refusing here would make + // launch stricter than chat entry for a DM the router can deliver. + let favorites = FavoritesPersistenceService(keychain: MockKeychain()) + let noiseKey = Data((0..<32).map(UInt8.init)) + favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice") + + let fullHexPeer = PeerID(str: noiseKey.hexEncodedString()) + XCTAssertTrue(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.isFavorite) + XCTAssertFalse(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.theyFavoritedUs) + XCTAssertTrue( + AppRuntime.isDirectChatRestorable( + fullHexPeer, + favorites: favorites, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_production_blockedMutualFavoriteIsNotRestorable() { + // A mutual favorite we have since blocked must NOT restore — mirrors the + // gate's block reject. Block state is injected (it lives in the identity + // manager, not the favorites store). + let favorites = FavoritesPersistenceService(keychain: MockKeychain()) + let noiseKey = Data((0..<32).map(UInt8.init)) + favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice") + favorites.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + + let fullHexPeer = PeerID(str: noiseKey.hexEncodedString()) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + fullHexPeer, + favorites: favorites, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in true } + ) + ) + } + + func test_production_unknownShortPeerIsNotRestorable() { + // A syntactically valid short peer with no favorite relationship must + // NOT restore (would otherwise open an empty phantom DM). Pins the real + // wiring so it cannot drift back to a syntax-only check. + let favorites = FavoritesPersistenceService(keychain: MockKeychain()) + XCTAssertTrue(peerID.isValid) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + peerID, + favorites: favorites, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + func test_production_unfavoritedPeerWhoStillFavoritesUsIsNotRestorable() { + // removeFavorite RETAINS a record (isFavorite: false, theyFavoritedUs: + // true) when the peer still favorites us. The resolver must key on + // isFavorite, not mere record existence — otherwise a DM to a peer we + // deliberately unfavorited reopens on restart. Regression for an + // earlier Codex review, and it SURVIVES the #1415 relaxation: the + // relaxed predicate admits our own favorite or a stored identity, and + // `theyFavoritedUs` is deliberately not a term, so the unfavorite still + // stands on its own. + // + // With a stored identity this peer would restore — see + // test_establishedNonFavoritePeerIsRestorable. That is the intended + // line: the durable evidence is then local capability rather than the + // other side's opinion of us. + let favorites = FavoritesPersistenceService(keychain: MockKeychain()) + let noiseKey = Data((0..<32).map(UInt8.init)) + favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice") + favorites.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true) + favorites.removeFavorite(peerNoisePublicKey: noiseKey) + + let fullHexPeer = PeerID(str: noiseKey.hexEncodedString()) + // The record survives (they still favorite us) but isFavorite is false. + XCTAssertNotNil(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())) + XCTAssertFalse(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.isFavorite) + XCTAssertTrue(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.theyFavoritedUs) + XCTAssertFalse( + AppRuntime.isDirectChatRestorable( + fullHexPeer, + favorites: favorites, + hasStoredCryptographicIdentity: { _ in false }, + isPeerBlocked: { _ in false } + ) + ) + } + + // MARK: - Helpers + + private func makeStorage() -> UserDefaults { + let suiteName = "ConversationStoreLastActiveTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + addTeardownBlock { + storage.removePersistentDomain(forName: suiteName) + } + return storage + } +}