Merge 3fc9f4db82b20ca067bffa613070776fbe997145 into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Eric Gang 2026-08-26 12:37:52 -04:00 committed by GitHub
commit 654a1d32e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 866 additions and 42 deletions

View File

@ -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()

View File

@ -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

View File

@ -375,6 +375,145 @@ final class ConversationStore: ObservableObject {
let changes = PassthroughSubject<ConversationChange, Never>()
// 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()
}

View File

@ -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"
}
}
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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")

View File

@ -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 <https://unlicense.org>
//
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
}
}