mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
Merge 3fc9f4db82b20ca067bffa613070776fbe997145 into 9b84b361225facd8e623f25d76f889d3dc54a879
This commit is contained in:
commit
654a1d32e7
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
206
bitchatTests/ConversationStoreLastActiveTests.swift
Normal file
206
bitchatTests/ConversationStoreLastActiveTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user