diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 51cadfb0..bfaa3bb8 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -21,6 +21,15 @@ final class AppChromeModel: ObservableObject { /// connectivity banner. Mirrored from `ChatViewModel.torBlocked`. @Published private(set) var torBlocked = false @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 /// Triple-tapping the logo asks first; the dialog lives on the header. @Published var showPanicConfirmation = false /// Mirrors `ChatViewModel.panicRecoveryBlocked` for the chrome: a wipe @@ -137,6 +146,11 @@ final class AppChromeModel: ObservableObject { isLocationChannelsSheetPresented = false isNoticesSheetPresented = false showingFingerprintFor = nil + // The people/conversation-list sheet is presented on this latch too + // (`ContentView.isPeopleSheetPresented`), and launch restore can raise + // it without anyone touching the screen — so it hides the outcome + // exactly like the sheets above unless it is cleared here. + showSidebar = false prepareForPanic?() onPanicWipe() diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 3d5bc5bc..15b8e88a 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -153,11 +153,45 @@ final class AppRuntime: ObservableObject { GeohashPresenceService.shared.start() checkForSharedContent() performMediaMaintenance() + restoreLastActiveConversationOnLaunch() record(.launched) record(.startupCompleted) } + /// #1064: decide what launch presents. A persisted public channel defers + /// to the existing mesh / `GeoChannelCoordinator` restore (the sole + /// launch-time writer of `activeChannel`), so there is no race; everything + /// else raises the conversation list. + /// + /// Launch deliberately does not re-open a persisted DM. Opening it would + /// put a contact's name and message history on screen before anyone chose + /// to show them, and nothing gates that today (the app-lock work is #1664). + /// The list is what #1064 actually asked for, and since #1598 it carries a + /// recent-chats section, so the last conversation is one tap away rather + /// than lost. + private func restoreLastActiveConversationOnLaunch() { + if Self.shouldPresentConversationList(for: conversations.restoreLastActiveConversation()) { + appChromeModel.showSidebar = true + } + } + + /// The launch decision itself, split out as a pure function so the mapping + /// is testable without standing up an `AppRuntime`. It is one comparison, + /// but it is the comparison that decides whether a public timeline or a + /// list ends up on screen, and inverting it silently is exactly the kind of + /// mistake the store-level tests cannot see. + static func shouldPresentConversationList( + for presentation: ConversationStore.LaunchPresentation + ) -> Bool { + switch presentation { + case .conversationList: + return true + case .deferToChannelRestore: + return false + } + } + /// Drops media that has outlived the retention window, then applies the /// explicit protection class to files that older builds wrote without /// one. Expiry runs first so the migration never touches files the diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 76e71813..89c803ea 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -375,6 +375,145 @@ 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. Launch never re-opens the + /// DM itself: landing on a name and message history would make + /// startup a disclosure to anyone holding an unlocked phone, and the + /// old restorability rule selected favorites — the relationships worth + /// most to an observer. The list still gets #1064's ask (don't start + /// typing into a broadcast nobody chose), and #1598's recent-chats + /// section puts the conversation one tap away. + case conversationList + /// 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) + } + + /// Whether a last-active pointer is on disk right now. Test-only, and it + /// exists because the panic wipe's guarantee is that the pointer is *gone* + /// — which launch cannot report: an absent record and a surviving + /// `.direct` one both present as `.conversationList`, so a test reading + /// only the presentation passes while a peer id survives an emergency + /// clear. Goes through the same private `lastActiveKey` production uses, + /// so renaming the key moves the test with it instead of leaving it + /// checking a string nothing writes. + var _test_hasPersistedLastActive: Bool { + storage.data(forKey: lastActiveKey) != nil + } + + /// 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. + /// + /// A persisted DM resolves to the conversation list, not to the DM, which + /// makes the peer's addressability irrelevant here — that is why no + /// resolvability predicate is threaded in. A restorable peer and a stale + /// one take the same branch, so there is nothing left for one to decide. + func restoreLastActiveConversation() -> LaunchPresentation { + guard let record = restoredLastActive else { return .conversationList } + switch record.kind { + case .mesh, .location: + return .deferToChannelRestore + case .direct: + return .conversationList + } + } + // MARK: Intent API /// Returns the conversation for `id`, creating it (with the cap policy @@ -556,6 +695,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() } @@ -565,6 +709,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 e498a20e..3d657e29 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -56139,6 +56139,378 @@ } } }, + "content.header.public_caption" : { + "comment" : "Trust caption under the public mesh timeline: this channel is public and reaches nearby devices. Sole carrier of that disclosure — the composer placeholder deliberately does not repeat it (#1595).", + "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" : { @@ -57073,181 +57445,181 @@ "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "رسالة #mesh — عام، قريب" + "value" : "رسالة #mesh" } }, "bn" : { "stringUnit" : { "state" : "needs_review", - "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" + "value" : "#mesh-এ বার্তা" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "nachricht an #mesh — öffentlich, in der nähe" + "value" : "nachricht an #mesh" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "message #mesh — public, nearby" + "value" : "message #mesh" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "mensaje #mesh — público, cerca" + "value" : "mensaje #mesh" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "پیام به #mesh — عمومی، اطراف شما" + "value" : "پیام به #mesh" } }, "fil" : { "stringUnit" : { "state" : "needs_review", - "value" : "mensahe sa #mesh — pampubliko, malapit" + "value" : "mensahe sa #mesh" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "message vers #mesh — public, à proximité" + "value" : "message vers #mesh" } }, "he" : { "stringUnit" : { "state" : "needs_review", - "value" : "הודעה ל-#mesh — ציבורי, קרוב" + "value" : "הודעה ל-#mesh" } }, "hi" : { "stringUnit" : { "state" : "needs_review", - "value" : "संदेश #mesh — सार्वजनिक, आसपास" + "value" : "संदेश #mesh" } }, "id" : { "stringUnit" : { "state" : "needs_review", - "value" : "pesan #mesh — publik, terdekat" + "value" : "pesan #mesh" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "messaggio a #mesh — pubblico, nelle vicinanze" + "value" : "messaggio a #mesh" } }, "ja" : { "stringUnit" : { "state" : "needs_review", - "value" : "#mesh にメッセージ — 公開、近くの人へ" + "value" : "#mesh にメッセージ" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "#mesh 에 메시지 — 공개, 근처" + "value" : "#mesh 에 메시지" } }, "ms" : { "stringUnit" : { "state" : "needs_review", - "value" : "pesan #mesh — awam, berdekatan" + "value" : "pesan #mesh" } }, "ne" : { "stringUnit" : { "state" : "needs_review", - "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" + "value" : "#mesh मा सन्देश" } }, "nl" : { "stringUnit" : { "state" : "needs_review", - "value" : "bericht naar #mesh — openbaar, in de buurt" + "value" : "bericht naar #mesh" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "wiadomość #mesh — publiczna, w pobliżu" + "value" : "wiadomość #mesh" } }, "pt" : { "stringUnit" : { "state" : "needs_review", - "value" : "mensagem para #mesh — público, próximo" + "value" : "mensagem para #mesh" } }, "pt-BR" : { "stringUnit" : { "state" : "needs_review", - "value" : "mensagem para #mesh — público, por perto" + "value" : "mensagem para #mesh" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "сообщение в #mesh — публичное, рядом" + "value" : "сообщение в #mesh" } }, "sv" : { "stringUnit" : { "state" : "needs_review", - "value" : "meddelande #mesh — publikt, i närheten" + "value" : "meddelande #mesh" } }, "ta" : { "stringUnit" : { "state" : "needs_review", - "value" : "#mesh க்கு செய்தி — பொது, அருகில்" + "value" : "#mesh க்கு செய்தி" } }, "th" : { "stringUnit" : { "state" : "needs_review", - "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" + "value" : "ส่งข้อความถึง #mesh" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "mesaj #mesh — herkese açık, yakında" + "value" : "mesaj #mesh" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "повідомлення #mesh — публічне, поблизу" + "value" : "повідомлення #mesh" } }, "ur" : { "stringUnit" : { "state" : "needs_review", - "value" : "پیغام #mesh — عوامی، قریبی" + "value" : "پیغام #mesh" } }, "vi" : { "stringUnit" : { "state" : "needs_review", - "value" : "nhắn #mesh — công khai, gần đây" + "value" : "nhắn #mesh" } }, "zh-Hans" : { "stringUnit" : { "state" : "needs_review", - "value" : "发消息到 #mesh — 公开、附近" + "value" : "发消息到 #mesh" } }, "zh-Hant" : { "stringUnit" : { "state" : "needs_review", - "value" : "發訊息到 #mesh — 公開、附近" + "value" : "發訊息到 #mesh" } } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..640641a1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1592,6 +1592,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 db7b0712..012502ba 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -102,7 +102,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 @@ -140,7 +141,7 @@ struct ContentView: View { private var usesGlassLayout: Bool { appTheme.usesGlassChrome } private var isPeopleSheetPresented: Bool { - showSidebar || selectedPrivatePeerID != nil + appChromeModel.showSidebar || selectedPrivatePeerID != nil } private func rootModalPresentationState( @@ -255,7 +256,7 @@ struct ContentView: View { #endif .onChange(of: selectedPrivatePeerID) { newValue in if newValue != nil { - showSidebar = true + appChromeModel.showSidebar = true } sharedContentImportModel.updateDestination(sharedContentDestination) } @@ -267,7 +268,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. @@ -284,7 +285,7 @@ struct ContentView: View { ) { #if os(iOS) ContentPeopleSheetView( - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, messageText: $messageText, selectedMessageSender: $selectedMessageSender, selectedMessageSenderID: $selectedMessageSenderID, @@ -313,7 +314,7 @@ struct ContentView: View { .environmentObject(privateInboxModel) #else ContentPeopleSheetView( - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, messageText: $messageText, selectedMessageSender: $selectedMessageSender, selectedMessageSenderID: $selectedMessageSenderID, @@ -346,7 +347,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 { @@ -356,7 +357,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 @@ -372,7 +373,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 @@ -464,7 +465,10 @@ struct ContentView: View { } .safeAreaInset(edge: .bottom, spacing: 0) { if selectedPrivatePeerID == nil { - composerView + VStack(spacing: 0) { + meshPrivacyCaption + composerView + } } } } else { @@ -485,16 +489,36 @@ 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 { VStack(spacing: 0) { ContentHeaderView( - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, showVerifySheet: $showVerifySheet, isNicknameFieldFocused: $isNicknameFieldFocused, headerHeight: headerHeight, @@ -545,7 +569,7 @@ struct ContentView: View { imagePreviewURL: $imagePreviewURL, windowCountPublic: $windowCountPublic, windowCountPrivate: $windowCountPrivate, - showSidebar: $showSidebar, + showSidebar: $appChromeModel.showSidebar, isTextFieldFocused: $isTextFieldFocused ) } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 747e737b..ca4fd621 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -501,6 +501,17 @@ struct AppArchitectureTests { chromeModel.isLocationChannelsSheetPresented = true chromeModel.presentNotices() chromeModel.showFingerprint(for: PeerID(str: "peer-3")) + // The people/conversation-list sheet counts: ContentView presents it + // whenever `showSidebar || selectedPrivatePeerID != nil`, so a latch + // left set covers the root timeline exactly like the others. Launch + // restore (#1064) can raise it without the person touching anything. + chromeModel.showSidebar = true + // The OTHER arm of `isPeopleSheetPresented`. Asserting only the latch + // would leave the test green while a selected DM kept the sheet over + // the wipe result — the selection is cleared by pre-existing code in + // ChatViewModel.panicClearAllData, so this pins a cross-model + // invariant that neither half owns alone. + viewModel.startPrivateChat(with: PeerID(str: "1122334455667788")) chromeModel.panicClearAllData() @@ -508,6 +519,8 @@ struct AppArchitectureTests { #expect(!chromeModel.isLocationChannelsSheetPresented) #expect(!chromeModel.isNoticesSheetPresented) #expect(chromeModel.showingFingerprintFor == nil) + #expect(!chromeModel.showSidebar) + #expect(viewModel.selectedPrivateChatPeer == nil) } @Test("Recent chats list direct conversations with people absent from the rosters") diff --git a/bitchatTests/ConversationStoreLastActiveTests.swift b/bitchatTests/ConversationStoreLastActiveTests.swift new file mode 100644 index 00000000..d9041ce3 --- /dev/null +++ b/bitchatTests/ConversationStoreLastActiveTests.swift @@ -0,0 +1,206 @@ +// +// 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 persisted DM or a first-ever launch presents the +// conversation list, and a public channel defers to the existing +// GeoChannelCoordinator restore. +// +// Launch never re-opens the DM itself. The tests below pin that as a +// property, not an implementation detail: it is what keeps startup from +// disclosing a contact's name and message history to whoever is holding +// the phone. +// +// 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 presents the list. + let session1 = ConversationStore(storage: storage) + session1.setSelectedPrivatePeer(peerID) + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .conversationList + ) + + // Close the DM back to the mesh channel: the write must happen again, + // so the next launch now defers to the channel restore. + session1.setSelectedPrivatePeer(nil) + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .deferToChannelRestore + ) + } + + func test_persistedDirectChatPresentsTheListAndNeverTheChat() { + // The security property this design turns on: whatever was persisted, + // a DM never re-opens itself at launch. There is deliberately no + // "restorable peer" branch to take — a favorite, a merely-known peer + // and a stale one all land on the list — so no addressability check + // can put a name and a history on screen before anyone asked. + let storage = makeStorage() + ConversationStore(storage: storage).setSelectedPrivatePeer(peerID) + + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .conversationList + ) + } + + func test_launchDefersToChannelRestore_forAPersistedPublicChannel() { + // A public-channel restore is owned by GeoChannelCoordinator; the + // launch decision must not present the list on top of it. + let storage = makeStorage() + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(peerID) + session.setSelectedPrivatePeer(nil) + + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .deferToChannelRestore + ) + } + + 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, and GeoChannelCoordinator re-asserts the SAME (default + // mesh) active channel. Because the channel does not actually change, + // the persist is guarded out — so the `.direct` record on disk must + // survive rather than be overwritten with `.mesh`. The distinction + // still matters with the DM no longer re-opening: `.direct` presents + // the list, `.mesh` would defer to the channel restore instead. + let launch = ConversationStore(storage: storage) + launch.setActiveChannel(launch.activeChannel) + + // Session 3: the DM record is intact, so launch still presents the list. + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .conversationList + ) + } + + 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 channel record is persisted and would otherwise defer. + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(peerID) + session.setSelectedPrivatePeer(nil) + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .deferToChannelRestore + ) + + // 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(), + .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: + // beginPanicWipe → setSelectedPrivatePeer(nil) → setActiveChannel(.mesh) → finishPanicWipe + // Without the fix those resets re-persist a `.mesh` record, and the next + // launch would `.deferToChannelRestore` instead of `.conversationList`. + // + // The presentation assertion alone is NOT enough, and the earlier + // version of this comment overclaimed. `.conversationList` is the + // answer for an absent record AND for a surviving `.direct` one, so a + // regression where `finishPanicWipe` stopped removing the key while + // suppression still worked would leave the peer id on disk and this + // test would stay green. For a panic wipe that is the whole point of + // the feature, so assert the stored key is gone, not just that launch + // looks right. + let storage = makeStorage() + + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(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 + + // The pointer is truly absent, not rewritten as `.mesh` and not left + // behind as `.direct`. This is the only assertion that separates + // "erased" from "still on disk", since both present as + // `.conversationList` at launch. Asked through the store rather than + // by key literal: a literal here would go quietly vacuous the day the + // key is renamed, checking a string nothing writes. + XCTAssertFalse(ConversationStore(storage: storage)._test_hasPersistedLastActive) + + // And launch presents the list rather than deferring to a resurrected + // `.mesh` record — the suppression half of the same fix. + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .conversationList + ) + } + + // MARK: - Launch decision + + func test_launchPresentsTheListForAConversationListPresentation() { + XCTAssertTrue(AppRuntime.shouldPresentConversationList(for: .conversationList)) + } + + func test_launchLeavesAPublicChannelRestoreAlone() { + // Inverting this is how a geohash restore would end up buried under the + // people sheet, or a DM launch would land on the public timeline. + XCTAssertFalse(AppRuntime.shouldPresentConversationList(for: .deferToChannelRestore)) + } + + func test_firstLaunchPresentsConversationList() { + let storage = makeStorage() + + XCTAssertEqual( + ConversationStore(storage: storage).restoreLastActiveConversation(), + .conversationList + ) + } + + // 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 + } +}