mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Merge 5a81fcf3dd9102186f81c153027d8ec59a637ab0 into 4226f01503a14816bcaf45bd4161288034461ce3
This commit is contained in:
commit
765a0f3b8c
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
610
bitchatTests/ConversationStoreLastActiveTests.swift
Normal file
610
bitchatTests/ConversationStoreLastActiveTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user