Merge 5a81fcf3dd9102186f81c153027d8ec59a637ab0 into 4226f01503a14816bcaf45bd4161288034461ce3

This commit is contained in:
ecgang 2026-07-30 14:19:50 +00:00 committed by GitHub
commit 765a0f3b8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1455 additions and 23 deletions

View File

@ -18,6 +18,15 @@ final class AppChromeModel: ObservableObject {
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false
/// Latch for the people / conversation-list sheet. Owned here (rather than as
/// `ContentView` local `@State`) so non-view launch code can raise it: on launch
/// `AppRuntime` sets this to `true` when the last-active conversation resolves to
/// "present the conversation list" (first-ever launch or a stale/unrestorable DM
/// peer). `ContentView` binds the people sheet directly to this, and every
/// competing sheet/cover already gates on `!showSidebar`, so a single latch keeps
/// the launch presentation from colliding with the fingerprint / image-picker
/// sheets (#1064).
@Published var showSidebar = false
private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void

View File

@ -153,11 +153,223 @@ final class AppRuntime: ObservableObject {
GeohashPresenceService.shared.start()
checkForSharedContent()
expireAgedMedia()
restoreLastActiveConversationOnLaunch()
record(.launched)
record(.startupCompleted)
}
/// #1064: restore the last-active conversation at launch. A persisted DM
/// re-opens via the normal private-chat path (which never writes
/// `activeChannel`); a first-ever launch or a stale DM peer presents the
/// conversation list; a public channel defers to the existing mesh /
/// `GeoChannelCoordinator` restore (the sole launch-time writer of
/// `activeChannel`), so there is no race.
private func restoreLastActiveConversationOnLaunch() {
let presentation = conversations.restoreLastActiveConversation(
isPeerResolvable: {
Self.isDirectChatRestorable(
$0,
favorites: .shared,
hasStoredCryptographicIdentity: {
!chatViewModel.identityManager
.getCryptoIdentitiesByPeerIDPrefix($0.toShort())
.isEmpty
},
isPeerBlocked: { chatViewModel.isPeerBlocked($0) }
)
}
)
var didOpenDirectChat = false
if case .restoredDirectChat(let peerID) = presentation {
// `startPrivateChat`'s gate (ChatPeerIdentityCoordinator) rejects a
// now-blocked peer by emitting a system message and returning
// WITHOUT opening the chat. At launch that message would land in
// the current (public mesh) timeline, so pass
// `suppressSystemMessages: true` the reject stays silent and we
// detect it via `selectedPrivateChatPeer`, which is only set on the
// success path.
//
// Not a second line of defence any more. Post-#1415 that gate
// screens only self, group and blocked, so it catches nothing
// `isDirectChatRestorable` has not already caught. It is kept for
// the narrow race where the peer is blocked between the predicate
// and this call, and for the silent-failure detection above.
chatViewModel.startPrivateChat(with: peerID, suppressSystemMessages: true)
didOpenDirectChat = chatViewModel.selectedPrivateChatPeer == peerID
}
// Fall back to the conversation list rather than silently landing on
// the public mesh timeline when a restore target existed but could not
// be opened.
if Self.shouldPresentConversationList(for: presentation, didOpenDirectChat: didOpenDirectChat) {
appChromeModel.showSidebar = true
}
}
/// Whether a persisted last-active DM peer is genuinely restorable at
/// launch validated against *durable* relationship state, never live
/// presence (mesh discovery is async, so no peer is connected yet). A
/// syntactically valid `PeerID` is NOT sufficient: an unknown peer would
/// otherwise fall straight through `startPrivateChat` into an empty phantom
/// DM.
///
/// Restorable iff the peer is NOT blocked and we hold durable evidence
/// *locally* that the conversation is addressable: we favorited them, or we
/// have a stored cryptographic identity for them.
///
/// `theyFavoritedUs` is deliberately NOT a term. It is remote state, and on
/// its own it proves nothing about our ability to address the peer a peer
/// who favorited us, whom we never favorited and hold no identity for, is
/// exactly the unaddressable phantom this predicate exists to refuse. It
/// would also override a deliberate local unfavorite on the strength of the
/// other side's opinion.
///
/// This tracks `ChatPeerIdentityCoordinator.startPrivateChat`, which #1415
/// relaxed it no longer requires a mutual favorite, on the grounds that
/// store-and-forward (couriers, bridge drops, retained outbox) needs only
/// the recipient's noise key, so "the router decides what delivery looks
/// like, not chat entry". Keeping the old mutual-favorite rule here would
/// have made launch-restore stricter than chat entry: a one-way-favorite or
/// merely-known peer whose DM is perfectly sendable would fail to restore
/// and drop the user on the conversation list instead.
///
/// It cannot simply defer to that gate, though. Post-#1415 the open path
/// screens only self, group and blocked, so this predicate is the sole
/// defence against restoring into a phantom DM, and it has to hold the line
/// on its own.
///
/// The terms are chosen for durability at launch. Favorites are
/// keychain-backed; stored cryptographic identities are on disk. The outbox
/// and live Noise session state are deliberately NOT consulted the outbox
/// defers loading until protected data is available and session state is
/// in-memory, so both read empty at launch regardless of the truth.
///
/// A private group is a virtual conversation, so none of the peer terms
/// apply to it and none of them would pass: a group id is `group_` plus 32
/// hex, and both lookups guard on `isShort` (a 16-hex bare), so they return
/// empty for every group without ever consulting the group. Left to the
/// peer terms a group would therefore never restore silently, every
/// time. `startPrivateChat` gates group re-entry on nothing at all ("no
/// peer identity, favorites, handshake just select the chat"), so
/// admitting groups here restores what re-entry would have opened. (Not
/// quite an identity: the block veto above has no counterpart in
/// `startPrivateChat`'s group branch, so restore is the narrower of the
/// two. Moot today `isBlocked` needs a resolved fingerprint and a group
/// id never has one but stated rather than leaned on.)
///
/// Note the bound on that admission: `isGroup` tests the `group_` prefix
/// only `PeerID(str:)` assigns a prefix by `hasPrefix` and never
/// validates the bare so this branch admits any persisted id *claiming*
/// to be a group, not a group proven to exist.
///
/// That is acceptable, but for a narrower reason than "the id is trusted".
/// A group id can certainly originate remotely: an invite carries one, and
/// `GroupProtocol` derives the conversation id from it. Every such id is
/// built by `PeerID(groupID:)`, which hex-encodes the raw bytes, so a
/// group learned from the network still has a structurally valid bare. A
/// malformed `group_` id therefore implies corrupted local persistence
/// rather than hostile input. And the failure it produces is an empty
/// group, not a DM compose box aimed at an unreachable peer which is the
/// specific hazard this predicate exists to prevent. Admitting only groups
/// that still exist locally would need a membership lookup this predicate
/// does not take.
///
/// Geohash/Nostr ids are screened before the peer terms, so nothing can
/// bypass the check by matching earlier. A geoChat id is not a direct chat
/// at all. A geoDM's full Nostr key is rebuilt only from inbound ephemeral
/// events, so at launch it cannot resolve, and `startPrivateChat` skips the
/// handshake for geoDMs a phantom would open with no error at all. Its
/// only conceivable durable anchor is our own favorite record.
///
/// In practice that anchor does not exist yet, so **a geoDM never restores
/// today**. `FavoritesPersistenceService` is keyed by Noise public key
/// alone, and `getFavoriteStatus(forPeerID:)` matches by rebuilding
/// `PeerID(publicKey:)` which carries no prefix so it can never equal a
/// `nostr_`-prefixed id no matter what is favorited. The geoDM branch below
/// is kept because it is the right shape once a Nostr-keyed lookup exists,
/// but read it precisely: it resolves to `false` for the *production*
/// closure only. The injected seam will happily return `true` for a stub
/// that accepts a geoDM, so a passing test here is not evidence that geoDM
/// restore works. Documented rather than fixed: wiring the real lookup
/// means new favorites plumbing, which is not this change.
static func isDirectChatRestorable(
_ peerID: PeerID,
isPeerFavorited: (PeerID) -> Bool,
hasStoredCryptographicIdentity: (PeerID) -> Bool,
isPeerBlocked: (PeerID) -> Bool
) -> Bool {
// Blocked is a veto, never one term among several including over the
// group branch below, so no id class can sidestep it.
guard !isPeerBlocked(peerID) else { return false }
// A private group is local state, not a claim about reaching a peer.
// The peer terms below cannot represent it and would refuse it.
if peerID.isGroup { return true }
// A geohash channel is not a direct chat.
guard !peerID.isGeoChat else { return false }
// Screened before the identity term rather than after, so no earlier
// match can skip it. The identity lookup does not reject these on its
// own merits either: a `nostr_` id satisfies `isShort` (the prefix is
// not part of the length check), and today it misses only because the
// lookup re-attaches that prefix and no hex fingerprint starts with it.
// Luck, not a rule.
if peerID.isGeoDM { return isPeerFavorited(peerID) }
return isPeerFavorited(peerID) || hasStoredCryptographicIdentity(peerID)
}
/// Production wiring of `isDirectChatRestorable`, extracted so the real
/// favorites/block lookups (not just stub predicates) are unit-testable via
/// an injected in-memory-keychain-backed `FavoritesPersistenceService` and a
/// block closure. `migrateSelectedConversationIfNeeded` can persist the
/// last-active peer in full 64-hex Noise-key form, but the favorites store is
/// keyed by the short, Noise-key-derived id so normalize with `toShort()`
/// (a no-op on an already-short id) before the lookup, or favorited DMs
/// silently fail to restore. The block lookup mirrors the open-path gate's
/// `unifiedIsBlocked` (fingerprint-resolved, so it works for offline
/// favorites).
///
/// The identity lookup is passed in rather than reached for: it lives on
/// the injected `SecureIdentityStateManagerProtocol`, so tests stub it the
/// same way they stub the favorites service instead of sharing
/// process-wide state. It needs the same `toShort()` normalization
/// `getCryptoIdentitiesByPeerIDPrefix` guards on `isShort` and returns an
/// empty result otherwise, so a 64-hex id would read as "no identity"
/// rather than as an error. Synchronous and disk-backed, so it is safe on
/// the launch path.
static func isDirectChatRestorable(
_ peerID: PeerID,
favorites: FavoritesPersistenceService,
hasStoredCryptographicIdentity: (PeerID) -> Bool,
isPeerBlocked: (PeerID) -> Bool
) -> Bool {
isDirectChatRestorable(
peerID,
isPeerFavorited: {
favorites.getFavoriteStatus(forPeerID: $0.toShort())?.isFavorite ?? false
},
hasStoredCryptographicIdentity: hasStoredCryptographicIdentity,
isPeerBlocked: isPeerBlocked
)
}
/// Pure launch-effect decision, extracted so the fallback is unit-testable
/// without constructing `AppRuntime`: present the conversation list on a
/// first-ever launch, or when a persisted DM could not actually be opened
/// (blocked / stale / gated peer). A public-channel restore is left to
/// `GeoChannelCoordinator`.
static func shouldPresentConversationList(
for presentation: ConversationStore.LaunchPresentation,
didOpenDirectChat: Bool
) -> Bool {
switch presentation {
case .conversationList:
return true
case .restoredDirectChat:
return !didOpenDirectChat
case .deferToChannelRestore:
return false
}
}
/// Drops media that has outlived the retention window. Off the main thread
/// and best-effort: the sweep walks the media tree, and nothing at launch
/// depends on its result.

View File

@ -372,6 +372,129 @@ 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 whose peer no longer resolves.
case conversationList
/// A valid persisted DM the caller re-opens it via the normal
/// private-chat path (this axis never writes `activeChannel`).
case restoredDirectChat(PeerID)
/// Last-active was a public channel defer to the existing mesh /
/// `GeoChannelCoordinator` restore; do nothing here.
case deferToChannelRestore
}
/// Default persistence store for the last-active conversation. Production
/// uses `.standard`. Under test, a dedicated scratch suite is used instead
/// wiped at first use per process so the ~48 no-arg `ConversationStore()`
/// tests never pollute the real app's `.standard` `conversation.lastActive`
/// and back-to-back local runs never see each other's persisted selection.
/// Mirrors `ChatViewModel.defaultReadReceiptsDefaults`.
static let defaultStorage: UserDefaults = {
guard TestEnvironment.isRunningTests else { return .standard }
let suiteName = "chat.bitchat.tests.conversationStore"
guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard }
scratch.removePersistentDomain(forName: suiteName)
return scratch
}()
init(storage: UserDefaults = ConversationStore.defaultStorage) {
self.storage = storage
if let data = storage.data(forKey: lastActiveKey),
let record = try? JSONDecoder().decode(LastActiveRecord.self, from: data) {
self.restoredLastActive = record
} else {
self.restoredLastActive = nil
}
}
/// Persists the current foreground conversation. Called from the two
/// single-writer selection paths on every switch; an open DM wins over
/// the active channel.
private func persistLastActive() {
// Panic wipe suppresses last-active persistence so the selection/channel
// resets that follow it can't re-write the pointer being cleared.
guard !isPanicWiping else { return }
let record: LastActiveRecord
if let peerID = selectedPrivatePeerID {
record = LastActiveRecord(kind: .direct, peerID: peerID.id)
} else if activeChannel.isLocation {
record = LastActiveRecord(kind: .location, peerID: nil)
} else {
record = LastActiveRecord(kind: .mesh, peerID: nil)
}
if let data = try? JSONEncoder().encode(record) {
storage.set(data, forKey: lastActiveKey)
}
}
/// Erases the persisted last-active conversation. Called from the panic
/// wipe so a restored DM/channel pointer cannot survive an emergency clear.
/// The store owns its injected `storage`, so the key is removed through it
/// (never by reaching into `.standard`), and `lastActiveKey` stays private.
func clearPersistedLastActive() {
storage.removeObject(forKey: lastActiveKey)
}
/// Begins a panic wipe: suppress last-active persistence so the selection/channel
/// resets that follow cannot re-write the pointer we're about to remove.
func beginPanicWipe() { isPanicWiping = true }
/// Finishes a panic wipe: remove the persisted pointer and re-enable persistence.
/// MUST be called AFTER all selection/channel state has been reset.
func finishPanicWipe() {
clearPersistedLastActive()
isPanicWiping = false
}
/// Decides what to present at launch from the value persisted last
/// session. Pure aside from reading the init snapshot: performs no
/// selection mutation and never writes `activeChannel`, so it cannot race
/// the location-channel restore. `isPeerResolvable` lets the caller reject
/// a stale/unaddressable DM peer, falling back to the conversation list.
func restoreLastActiveConversation(isPeerResolvable: (PeerID) -> Bool) -> LaunchPresentation {
guard let record = restoredLastActive else { return .conversationList }
switch record.kind {
case .mesh, .location:
return .deferToChannelRestore
case .direct:
guard let raw = record.peerID, !raw.isEmpty else { return .conversationList }
let peerID = PeerID(str: raw)
guard isPeerResolvable(peerID) else { return .conversationList }
return .restoredDirectChat(peerID)
}
}
// MARK: Intent API
/// Returns the conversation for `id`, creating it (with the cap policy
@ -553,6 +676,11 @@ final class ConversationStore: ObservableObject {
func setActiveChannel(_ channelID: ChannelID) {
if activeChannel != channelID {
activeChannel = channelID
// Persist only on an ACTUAL change. A redundant re-apply of the
// current channel (e.g. `GeoChannelCoordinator` re-asserting the
// default mesh channel at launch) must not overwrite a persisted
// `.direct` record when a DM restore has just failed (#1064).
persistLastActive()
}
refreshDerivedSelection()
}
@ -562,6 +690,9 @@ final class ConversationStore: ObservableObject {
func setSelectedPrivatePeer(_ peerID: PeerID?) {
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
// Persist only on an actual selection change; the people-sheet
// dismissal re-invokes this with the same (nil) value.
persistLastActive()
}
refreshDerivedSelection()
}

View File

@ -37912,6 +37912,378 @@
}
}
},
"content.header.public_caption" : {
"comment" : "Trust caption under the public mesh timeline: this channel is public and reaches nearby devices. Reuses the composer placeholder vocabulary (content.input.placeholder.mesh).",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "عام · قريب"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "পাবলিক · কাছাকাছি"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "öffentlich · in der nähe"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "public · nearby"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "público · cerca"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "عمومی · اطراف شما"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "pampubliko · malapit"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "public · à proximité"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "ציבורי · קרוב"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सार्वजनिक · आसपास"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "publik · terdekat"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "pubblico · nelle vicinanze"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "公開 · 近くの人へ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "공개 · 근처"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "awam · berdekatan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सार्वजनिक · नजिकको"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "openbaar · in de buurt"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "publiczna · w pobliżu"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "público · próximo"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "público · por perto"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "публичное · рядом"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "publikt · i närheten"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "பொது · அருகில்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "สาธารณะ · ใกล้เคียง"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "herkese açık · yakında"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "публічне · поблизу"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "عوامی · قریبی"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "công khai · gần đây"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "公开 · 附近"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "公開 · 附近"
}
}
}
},
"content.header.public_caption.a11y" : {
"comment" : "Accessibility label for the public mesh trust caption; comma form of content.header.public_caption for VoiceOver.",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "عام، قريب"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "পাবলিক, কাছাকাছি"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "öffentlich, in der nähe"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "public, nearby"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "público, cerca"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "عمومی، اطراف شما"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "pampubliko, malapit"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "public, à proximité"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "ציבורי, קרוב"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "सार्वजनिक, आसपास"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "publik, terdekat"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "pubblico, nelle vicinanze"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "公開、近くの人へ"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "공개, 근처"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "awam, berdekatan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "सार्वजनिक, नजिकको"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "openbaar, in de buurt"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "publiczna, w pobliżu"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "público, próximo"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "público, por perto"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "публичное, рядом"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "publikt, i närheten"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "பொது, அருகில்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "สาธารณะ, ใกล้เคียง"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "herkese açık, yakında"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "публічне, поблизу"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "عوامی، قریبی"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "công khai, gần đây"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "公开、附近"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "公開、附近"
}
}
}
},
"content.help.verification" : {
"extractionState" : "manual",
"localizations" : {

View File

@ -319,7 +319,7 @@ final class ChatPeerIdentityCoordinator {
}
@MainActor
func startPrivateChat(with peerID: PeerID) {
func startPrivateChat(with peerID: PeerID, suppressSystemMessages: Bool = false) {
guard peerID != context.myPeerID else { return }
// Group chats are virtual conversations: no peer identity, favorites,
@ -334,16 +334,18 @@ final class ChatPeerIdentityCoordinator {
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if context.unifiedIsBlocked(peerID) {
context.addSystemMessage(
String(
format: String(
localized: "system.chat.blocked",
comment: "System message when starting chat fails because peer is blocked"
),
locale: .current,
peerNickname
if !suppressSystemMessages {
context.addSystemMessage(
String(
format: String(
localized: "system.chat.blocked",
comment: "System message when starting chat fails because peer is blocked"
),
locale: .current,
peerNickname
)
)
)
}
return
}

View File

@ -1492,12 +1492,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
/// Initiates a private chat session with a peer.
/// - Parameter peerID: The peer's ID to start chatting with
/// - Note: Switches the UI to private chat mode and loads message history
/// - Note: Switches the UI to private chat mode and loads message history.
/// This zero-extra-argument signature is the witness for the
/// `CommandProcessor` / `ChatNostrCoordinator` context protocols, so it is
/// kept intact; the launch-restore variant is a separate overload below.
@MainActor
func startPrivateChat(with peerID: PeerID) {
peerIdentityCoordinator.startPrivateChat(with: peerID)
}
/// #1064 launch-restore variant of `startPrivateChat`. When
/// `suppressSystemMessages` is `true`, a gate rejection no-ops silently
/// instead of emitting a system message into the current (public mesh)
/// timeline, so a rejected DM restore falls back to the conversation list
/// cleanly. Since #1415 removed the mutual-favorite gate that is exactly
/// one message the blocked one. Kept as a distinct non-defaulted
/// overload a defaulted extra parameter would not satisfy the context
/// protocols above and a default would make the plain call ambiguous.
@MainActor
func startPrivateChat(with peerID: PeerID, suppressSystemMessages: Bool) {
peerIdentityCoordinator.startPrivateChat(with: peerID, suppressSystemMessages: suppressSystemMessages)
}
@MainActor
func endPrivateChat() {
peerIdentityCoordinator.endPrivateChat()
@ -1586,6 +1602,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
conversations.clearAll()
// Begin suppressing last-active persistence (#1064). The selection and
// channel resets below route through the store's setters, which would
// otherwise re-persist a `.mesh` pointer.
//
// Finished via `defer` rather than a call at the end: this method now
// has an early `return false` when the keychain wipe is incomplete, and
// suppression that outlives the wipe would silently stop persisting the
// last-active conversation for the rest of the process. `defer` also
// keeps the ordering the store requires it runs after every selection
// and channel reset below, so no setter can re-persist the pointer
// afterwards, and the pointer is removed exactly once, leaving the key
// absent so the next launch hits the conversation-list fallback.
conversations.beginPanicWipe()
defer { conversations.finishPanicWipe() }
pendingGeohashSystemMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys)

View File

@ -99,7 +99,8 @@ struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.appTheme) private var appTheme
@Environment(\.scenePhase) private var scenePhase
@State private var showSidebar = false
// `showSidebar` (the people/conversation-list sheet latch) lives on
// `AppChromeModel` so non-view launch code can raise it; see that property.
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@FocusState private var isNicknameFieldFocused: Bool
@ -137,7 +138,7 @@ struct ContentView: View {
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
private var isPeopleSheetPresented: Bool {
showSidebar || selectedPrivatePeerID != nil
appChromeModel.showSidebar || selectedPrivatePeerID != nil
}
private func rootModalPresentationState(
@ -252,7 +253,7 @@ struct ContentView: View {
#endif
.onChange(of: selectedPrivatePeerID) { newValue in
if newValue != nil {
showSidebar = true
appChromeModel.showSidebar = true
}
sharedContentImportModel.updateDestination(sharedContentDestination)
}
@ -264,7 +265,7 @@ struct ContentView: View {
get: { isPeopleSheetPresented },
set: { isPresented in
if !isPresented {
showSidebar = false
appChromeModel.showSidebar = false
// Scene/background and alert-presentation
// reconciliation (Bluetooth-off, recording errors)
// are not user requests to leave the conversation.
@ -281,7 +282,7 @@ struct ContentView: View {
) {
#if os(iOS)
ContentPeopleSheetView(
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
@ -299,7 +300,7 @@ struct ContentView: View {
)
#else
ContentPeopleSheetView(
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
@ -324,7 +325,7 @@ struct ContentView: View {
.environmentObject(locationChannelsModel)
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
get: { appChromeModel.showingFingerprintFor != nil && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
set: { _ in appChromeModel.clearFingerprint() }
)) {
if let peerID = appChromeModel.showingFingerprintFor {
@ -334,7 +335,7 @@ struct ContentView: View {
}
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil },
get: { showImagePicker && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
set: { newValue in
if !newValue {
showImagePicker = false
@ -350,7 +351,7 @@ struct ContentView: View {
#endif
#if os(macOS)
.sheet(isPresented: Binding(
get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil },
get: { showMacImagePicker && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
set: { newValue in
if !newValue {
showMacImagePicker = false
@ -439,7 +440,10 @@ struct ContentView: View {
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if selectedPrivatePeerID == nil {
composerView
VStack(spacing: 0) {
meshPrivacyCaption
composerView
}
}
}
} else {
@ -460,15 +464,35 @@ struct ContentView: View {
Divider()
if selectedPrivatePeerID == nil {
meshPrivacyCaption
composerView
}
}
}
}
/// Persistent trust caption under the PUBLIC mesh timeline the parity
/// twin of the DM sheet's `privacyCaption` (#1366). Moved here out of the
/// header's non-compressible trailing cluster, where its `.fixedSize` text
/// overflowed narrow (SE-width) headers. Mesh-only: geohash/location
/// channels carry no such caption. Muted rather than orange orange is the
/// DM privacy signal; this surface is deliberately public.
@ViewBuilder
private var meshPrivacyCaption: some View {
if case .mesh = locationChannelsModel.selectedChannel {
Text("content.header.public_caption")
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.themedSurface()
.accessibilityLabel(Text("content.header.public_caption.a11y"))
}
}
private var headerView: some View {
ContentHeaderView(
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
showVerifySheet: $showVerifySheet,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
@ -487,7 +511,7 @@ struct ContentView: View {
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
isTextFieldFocused: $isTextFieldFocused
)
}

View File

@ -303,6 +303,48 @@ struct ChatPeerIdentityCoordinatorContextTests {
#expect(context.markedReadPeers.isEmpty)
}
@Test @MainActor
func startPrivateChat_suppressed_blockedPeerEmitsNoSystemMessage() async {
// #1064: at launch a now-blocked (e.g. favorited-then-blocked) peer must
// reject SILENTLY no "blocked" line leaked into the public timeline
// while still not opening the chat, so the caller falls back to the list.
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.blockedPeers = [peerID]
coordinator.startPrivateChat(with: peerID, suppressSystemMessages: true)
#expect(context.systemMessages.isEmpty)
#expect(context.begunChatSessions.isEmpty)
#expect(context.consolidatedPeers.isEmpty)
#expect(context.markedReadPeers.isEmpty)
}
@Test @MainActor
func startPrivateChat_suppressed_oneWayFavoriteOpensChatSilently() async {
// #1064 × #1415: the mutual-favorite gate is gone (store-and-forward
// handles offline non-mutual favorites), so a one-way favorite now
// opens the chat and under suppression still emits no system message.
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data((0..<32).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
peer.favoriteStatus = makeFavoriteRelationship(
noiseKey: noiseKey,
isFavorite: true,
theyFavoritedUs: false
)
context.peersByID[peerID] = peer
coordinator.startPrivateChat(with: peerID, suppressSystemMessages: true)
#expect(context.systemMessages.isEmpty)
#expect(context.begunChatSessions == [peerID])
#expect(context.consolidatedPeers.map(\.peerID) == [peerID])
}
@Test @MainActor
func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async {
let context = MockChatPeerIdentityContext()

View File

@ -0,0 +1,610 @@
//
// ConversationStoreLastActiveTests.swift
// bitchatTests
//
// Tests for #1064 last-active persistence: ConversationStore records the
// foreground conversation on every switch and, at the next launch, decides
// what to present a valid DM restores, a stale DM or a first-ever launch
// falls back to the conversation list, and a public channel defers to the
// existing GeoChannelCoordinator restore.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <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 should restore it.
let session1 = ConversationStore(storage: storage)
session1.setSelectedPrivatePeer(peerID)
XCTAssertEqual(
ConversationStore(storage: storage).restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.restoredDirectChat(peerID)
)
// Close the DM back to the mesh channel: the write must happen again,
// so the next launch now defers to the channel restore, not the DM.
session1.setSelectedPrivatePeer(nil)
XCTAssertEqual(
ConversationStore(storage: storage).restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.deferToChannelRestore
)
}
func test_restoresValidDirectChat() {
let storage = makeStorage()
ConversationStore(storage: storage).setSelectedPrivatePeer(peerID)
let restored = ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { $0.isValid })
XCTAssertEqual(restored, .restoredDirectChat(peerID))
}
func test_staleDirectChatFallsBackToConversationList() {
let storage = makeStorage()
ConversationStore(storage: storage).setSelectedPrivatePeer(peerID)
// The persisted peer no longer resolves at launch.
let restored = ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in false })
XCTAssertEqual(restored, .conversationList)
}
func test_failedDirectRestoreDoesNotEraseDirectRecord() {
// #1064: a redundant channel re-apply must not clobber a persisted DM.
let storage = makeStorage()
// Session 1: the user is in a DM persists a `.direct` record.
ConversationStore(storage: storage).setSelectedPrivatePeer(peerID)
// Session 2 (launch): a fresh store starts with no private peer
// selected. The DM restore has just failed, and GeoChannelCoordinator
// re-asserts the SAME (default mesh) active channel. Because the channel
// does not actually change, the persist is now guarded out so the
// `.direct` record on disk must survive rather than be overwritten with
// `.mesh`.
let launch = ConversationStore(storage: storage)
launch.setActiveChannel(launch.activeChannel)
// Session 3: the DM record is intact and still restores.
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.restoredDirectChat(peerID)
)
}
func test_clearPersistedLastActiveErasesRestoreRecord() {
// #1064 panic wipe: clearing the persisted last-active pointer means a
// wiped DM/channel cannot be restored on the next launch.
let storage = makeStorage()
// A DM is persisted and would otherwise restore.
let session = ConversationStore(storage: storage)
session.setSelectedPrivatePeer(peerID)
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.restoredDirectChat(peerID)
)
// The panic path erases the pointer through the store's own storage.
session.clearPersistedLastActive()
// The next launch has nothing to restore and falls back to the list.
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.conversationList
)
}
func test_panicWipeOrderLeavesNoRestoreRecord() {
// #1064 panic wipe, FULL ORDER: `clearPersistedLastActive()` alone is
// insufficient because `panicClearAllData()` also runs
// `selectedPrivateChatPeer = nil` and `activeChannel = .mesh` AFTER it
// both route through the store's setters, whose guarded `persistLastActive()`
// re-writes a `.mesh` record on a real state change, resurrecting the
// pointer. The begin/finish suppression window fixes that. This test
// mirrors panicClearAllData's exact order against a single store:
// beginPanicWipe setSelectedPrivatePeer(nil) setActiveChannel(.mesh) finishPanicWipe
// Without the fix (i.e. without begin/finish), those selection/channel
// resets re-persist a `.mesh` record here `setSelectedPrivatePeer(nil)`
// is the real state change that writes it (the store's default channel
// is already `.mesh`, so `setActiveChannel(.mesh)` is the second trigger
// whenever panic runs from a non-mesh channel). Either way the pointer
// survives as `.mesh` and the next launch would `.deferToChannelRestore`
// instead of `.conversationList`.
let storage = makeStorage()
// The user is in a DM: a `.direct` record is persisted and would restore.
let session = ConversationStore(storage: storage)
session.setSelectedPrivatePeer(peerID)
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.restoredDirectChat(peerID)
)
// Reproduce panicClearAllData's order on the SAME store.
session.beginPanicWipe()
session.setSelectedPrivatePeer(nil) // re-persist trigger #1 (suppressed)
session.setActiveChannel(.mesh) // re-persist trigger #2 (suppressed)
session.finishPanicWipe() // removes the pointer once
// A fresh store on the SAME storage finds NO record and falls back to
// the conversation list the pointer is truly absent, not `.mesh`.
XCTAssertEqual(
ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true }),
.conversationList
)
}
func test_firstLaunchPresentsConversationList() {
let storage = makeStorage()
let restored = ConversationStore(storage: storage)
.restoreLastActiveConversation(isPeerResolvable: { _ in true })
XCTAssertEqual(restored, .conversationList)
}
// MARK: - Launch effect (silent-mesh fallback)
func test_launchPresentsList_whenRestoredDirectChatDidNotOpen() {
// A persisted DM whose peer is now blocked/stale/gated: startPrivateChat
// no-ops, so no chat opens must fall back to the conversation list,
// never silently land on the public mesh timeline.
XCTAssertTrue(
AppRuntime.shouldPresentConversationList(
for: .restoredDirectChat(peerID),
didOpenDirectChat: false
)
)
}
func test_launchDoesNotPresentList_whenRestoredDirectChatOpened() {
XCTAssertFalse(
AppRuntime.shouldPresentConversationList(
for: .restoredDirectChat(peerID),
didOpenDirectChat: true
)
)
}
func test_launchDefersToChannelRestore_withoutPresentingList() {
// A public-channel restore is owned by GeoChannelCoordinator; the
// launch decision must not present the list on top of it.
XCTAssertFalse(
AppRuntime.shouldPresentConversationList(
for: .deferToChannelRestore,
didOpenDirectChat: false
)
)
}
// MARK: - Restorability predicate (durable state, not syntax)
func test_unknownButSyntacticallyValidPeerIsNotRestorable() {
// Regression guard: a well-formed 16-hex peer id we have NO durable
// relationship with must NOT be treated as restorable. Otherwise it
// falls straight through startPrivateChat into an empty phantom DM.
// (The syntax-only `{ $0.isValid }` resolver missed exactly this.)
XCTAssertTrue(peerID.isValid)
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_mutualFavoritePeerIsRestorable() {
// A persisted MUTUAL favorite is stored by stable Noise public key and
// survives restart the one durable, presence-independent mesh
// relationship. Mirrors the open-path gate, which only lets an offline
// favorite through when the favorite is mutual.
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in true },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_oneWayFavoritePeerIsRestorable() {
// INVERTED by #1415, deliberately kept as the record of that change.
// This used to be false: the open path required a mutual favorite, so
// restoring a one-way favorite would have injected a "requires
// favorite" system message into the public timeline. #1415 removed that
// gate store-and-forward needs only the recipient's noise key so a
// one-way favorite is now perfectly sendable, and refusing to restore
// it would make launch stricter than chat entry.
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in true },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_peerWhoOnlyFavoritedUsIsNotRestorable() {
// Remote state alone is not evidence we can address them. They favorited
// us, we never favorited them, and we hold no stored identity the
// unaddressable phantom this predicate exists to refuse. Their opinion
// of us says nothing about whether we have a key for them.
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_geoChatIdIsNeverRestorableAsADirectChat() {
// A geohash channel id is not a direct chat at all, and the screen runs
// before the favorite term so nothing can match past it.
let geoChatID = PeerID(str: "nostr:01234567")
XCTAssertTrue(geoChatID.isGeoChat)
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
geoChatID,
isPeerFavorited: { _ in true },
hasStoredCryptographicIdentity: { _ in true },
isPeerBlocked: { _ in false }
)
)
}
func test_establishedNonFavoritePeerIsRestorable() {
// No favorite either way, but we hold a stored cryptographic identity
// durable, on disk, and enough for the router to deliver via courier or
// the retained outbox. Restoring it is not a phantom.
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in true },
isPeerBlocked: { _ in false }
)
)
}
func test_blockedEstablishedPeerIsNotRestorable() {
// Blocked is a veto, not one term among several: a stored identity must
// not buy its way past the block the way it passes the favorite terms.
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in true },
isPeerBlocked: { _ in true }
)
)
}
func test_geoDMWithStoredIdentityIsNotRestorable() {
// The identity term must NOT extend to geohash DMs. A `nostr_` id's
// full Nostr key is rebuilt only from inbound ephemeral events, and
// startPrivateChat skips the handshake for geoDMs, so a phantom would
// open with no error at all. Today the lookup would miss anyway (the
// id's prefix is re-attached and no hex fingerprint starts with it),
// which is luck this pins the refusal so a change to that lookup
// cannot quietly turn geoDM phantoms back on.
let geoDMPeer = PeerID(str: "nostr_0123456789abcdef")
XCTAssertTrue(geoDMPeer.isGeoDM)
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
geoDMPeer,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in true },
isPeerBlocked: { _ in false }
)
)
}
func test_geoDMWithFavoriteIsRestorable() {
// A favorite record IS a geoDM's durable anchor (it carries
// peerNostrPublicKey), so the favorite terms still admit one.
let geoDMPeer = PeerID(str: "nostr_0123456789abcdef")
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
geoDMPeer,
isPeerFavorited: { _ in true },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_privateGroupIsRestorable() {
// A group id is `group_` plus 32 hex, so both peer lookups guard on
// `isShort` and return empty for it. Left to those terms a group would
// never restore, silently, every time even though `startPrivateChat`
// gates group re-entry on nothing at all.
let group = PeerID(str: "group_" + String(repeating: "ab", count: 16))
XCTAssertTrue(group.isGroup, "test fixture is not a group id")
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
group,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_privateGroupNeverConsultsThePeerTerms() {
// Admitting groups by accident because some peer term happened to
// match would be a different bug wearing the same green check. The
// group must be admitted on its own branch, before either lookup runs.
let group = PeerID(str: "group_" + String(repeating: "cd", count: 16))
var favoriteLookups = 0
var identityLookups = 0
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
group,
isPeerFavorited: { _ in favoriteLookups += 1; return false },
hasStoredCryptographicIdentity: { _ in identityLookups += 1; return false },
isPeerBlocked: { _ in false }
)
)
XCTAssertEqual(favoriteLookups, 0, "group restore consulted the favorites term")
XCTAssertEqual(identityLookups, 0, "group restore consulted the identity term")
}
func test_idClassesAreMutuallyExclusive() {
// The group branch sits between the geoChat guard and the geoDM one,
// so its placement would be load-bearing if an id could belong to two
// classes at once. `PeerID` assigns exactly one prefix, so it cannot
// pin that, because the day it stops being true the ordering silently
// decides which rule wins.
let group = PeerID(str: "group_" + String(repeating: "ab", count: 16))
let geoDM = PeerID(str: "nostr_0123456789abcdef")
let geoChat = PeerID(str: "nostr:someGeohashChannel")
XCTAssertTrue(group.isGroup)
XCTAssertFalse(group.isGeoDM)
XCTAssertFalse(group.isGeoChat)
XCTAssertFalse(geoDM.isGroup)
XCTAssertFalse(geoChat.isGroup)
}
func test_malformedGroupIDIsStillAdmitted_documentingTheBound() {
// `isGroup` tests the prefix only `PeerID(str:)` never validates the
// bare so this branch admits any persisted id claiming to be a
// group. That is deliberate and bounded: the value is written by our
// own selection path into local state, never parsed from the network,
// and the failure it can produce is an empty group rather than the
// phantom DM this predicate exists to prevent. Pinned so that if the
// id ever becomes untrusted, this test is the thing that has to change.
let malformed = PeerID(str: "group_not-hex")
XCTAssertTrue(malformed.isGroup)
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
malformed,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_blockedGroupIsNotRestorable() {
// Block stays an unconditional veto, ahead of the group branch.
let group = PeerID(str: "group_" + String(repeating: "ef", count: 16))
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
group,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in true }
)
)
}
func test_blockedMutualFavoriteIsNotRestorable() {
// A blocked peer is never restorable, even if the favorite is mutual
// mirrors the gate's first (block) reject.
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
peerID,
isPeerFavorited: { _ in true },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in true }
)
)
}
func test_geoDMPeerWithoutMutualFavoriteIsNotRestorable() {
// #1064 phantom-DM fix: a geohash/Nostr DM id is NO LONGER special-cased
// as restorable. Its full Nostr key is rebuilt only from inbound
// ephemeral events, so at launch a restored `nostr_` id cannot resolve
// and would open an unsendable phantom. Only a mutual favorite restores.
let geoDMPeer = PeerID(str: "nostr_0123456789abcdef")
XCTAssertTrue(geoDMPeer.isGeoDM)
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
geoDMPeer,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_restoreWithProductionShapedResolver_unknownPeerYieldsConversationList() {
// End-to-end through ConversationStore using the PRODUCTION predicate
// shape (not a bare `{ _ in false }`): a persisted DM whose peer is
// unknown/unfavorited must present the conversation list, never restore
// a phantom DM. This is the test that would have caught the hole.
let storage = makeStorage()
ConversationStore(storage: storage).setSelectedPrivatePeer(peerID)
let restored = ConversationStore(storage: storage).restoreLastActiveConversation(
isPeerResolvable: {
AppRuntime.isDirectChatRestorable(
$0,
isPeerFavorited: { _ in false },
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
}
)
XCTAssertEqual(restored, .conversationList)
}
// MARK: - Production wiring (real FavoritesPersistenceService)
func test_production_fullHexMutualFavoritePeerIsRestorable() {
// migrateSelectedConversationIfNeeded persists the peer in FULL 64-hex
// Noise-key form; the favorites store is keyed by the short derived id.
// The production resolver must normalize (`toShort()`) so a favorited DM
// still restores. Regression for fix-round-3 finding 1. The favorite must
// be MUTUAL to mirror the open-path gate.
let favorites = FavoritesPersistenceService(keychain: MockKeychain())
let noiseKey = Data((0..<32).map(UInt8.init))
favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice")
favorites.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
let fullHexPeer = PeerID(str: noiseKey.hexEncodedString())
XCTAssertFalse(fullHexPeer.isShort) // 64-hex, not the short form
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
fullHexPeer,
favorites: favorites,
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_production_oneWayFavoriteIsRestorable() {
// INVERTED by #1415, through the real favorites wiring. This used to
// refuse a non-mutual favorite because the open-path gate would have
// rejected it at launch; that gate is gone, so refusing here would make
// launch stricter than chat entry for a DM the router can deliver.
let favorites = FavoritesPersistenceService(keychain: MockKeychain())
let noiseKey = Data((0..<32).map(UInt8.init))
favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice")
let fullHexPeer = PeerID(str: noiseKey.hexEncodedString())
XCTAssertTrue(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.isFavorite)
XCTAssertFalse(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.theyFavoritedUs)
XCTAssertTrue(
AppRuntime.isDirectChatRestorable(
fullHexPeer,
favorites: favorites,
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_production_blockedMutualFavoriteIsNotRestorable() {
// A mutual favorite we have since blocked must NOT restore mirrors the
// gate's block reject. Block state is injected (it lives in the identity
// manager, not the favorites store).
let favorites = FavoritesPersistenceService(keychain: MockKeychain())
let noiseKey = Data((0..<32).map(UInt8.init))
favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice")
favorites.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
let fullHexPeer = PeerID(str: noiseKey.hexEncodedString())
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
fullHexPeer,
favorites: favorites,
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in true }
)
)
}
func test_production_unknownShortPeerIsNotRestorable() {
// A syntactically valid short peer with no favorite relationship must
// NOT restore (would otherwise open an empty phantom DM). Pins the real
// wiring so it cannot drift back to a syntax-only check.
let favorites = FavoritesPersistenceService(keychain: MockKeychain())
XCTAssertTrue(peerID.isValid)
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
peerID,
favorites: favorites,
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
func test_production_unfavoritedPeerWhoStillFavoritesUsIsNotRestorable() {
// removeFavorite RETAINS a record (isFavorite: false, theyFavoritedUs:
// true) when the peer still favorites us. The resolver must key on
// isFavorite, not mere record existence otherwise a DM to a peer we
// deliberately unfavorited reopens on restart. Regression for an
// earlier Codex review, and it SURVIVES the #1415 relaxation: the
// relaxed predicate admits our own favorite or a stored identity, and
// `theyFavoritedUs` is deliberately not a term, so the unfavorite still
// stands on its own.
//
// With a stored identity this peer would restore see
// test_establishedNonFavoritePeerIsRestorable. That is the intended
// line: the durable evidence is then local capability rather than the
// other side's opinion of us.
let favorites = FavoritesPersistenceService(keychain: MockKeychain())
let noiseKey = Data((0..<32).map(UInt8.init))
favorites.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "Alice")
favorites.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
favorites.removeFavorite(peerNoisePublicKey: noiseKey)
let fullHexPeer = PeerID(str: noiseKey.hexEncodedString())
// The record survives (they still favorite us) but isFavorite is false.
XCTAssertNotNil(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort()))
XCTAssertFalse(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.isFavorite)
XCTAssertTrue(favorites.getFavoriteStatus(forPeerID: fullHexPeer.toShort())!.theyFavoritedUs)
XCTAssertFalse(
AppRuntime.isDirectChatRestorable(
fullHexPeer,
favorites: favorites,
hasStoredCryptographicIdentity: { _ in false },
isPeerBlocked: { _ in false }
)
)
}
// MARK: - Helpers
private func makeStorage() -> UserDefaults {
let suiteName = "ConversationStoreLastActiveTests-\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName)!
storage.removePersistentDomain(forName: suiteName)
addTeardownBlock {
storage.removePersistentDomain(forName: suiteName)
}
return storage
}
}