mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
feat: restore last-active conversation on launch; label mesh as public
bitchat always opened into the public mesh timeline regardless of where the user last was. Geohash channels are already restored at launch (LocationStateManager + GeoChannelCoordinator), but first-ever launches and DM-last sessions still landed in mesh, and nothing signalled that mesh is public and unencrypted. - Persist the last-active conversation in ConversationStore under a new `conversation.lastActive` key (mirrors the `locationChannel.selected` idiom), written on every switch in the single-writer selection paths. - At launch, restore a last-used DM via the normal private-chat path, fall back to the conversation list on a first-ever launch or a stale peer, and defer public-channel restore to the existing GeoChannelCoordinator, so no second launch-time writer of activeChannel is introduced. - Add a light "public · unencrypted" label to the mesh header. - Cover persistence and the launch decision with unit tests. activeChannel keeps its non-optional ChannelID type; no coordinator or send-path signatures change.
This commit is contained in:
parent
3ea4188699
commit
5a890559c1
@ -14,6 +14,10 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
/// #1064: set once at launch when the last-active conversation resolves to
|
||||
/// "present the conversation list" (first-ever launch or a stale DM peer).
|
||||
/// `ContentView` folds this into the people-sheet presentation binding.
|
||||
@Published var presentsConversationListOnLaunch = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@ -122,11 +122,92 @@ final class AppRuntime: ObservableObject {
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
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) }
|
||||
)
|
||||
var didOpenDirectChat = false
|
||||
if case .restoredDirectChat(let peerID) = presentation {
|
||||
// `startPrivateChat` silently no-ops when the peer is now blocked
|
||||
// or fails the mutual-favorite-and-connected gate
|
||||
// (ChatPeerIdentityCoordinator.startPrivateChat), leaving no chat
|
||||
// open — `selectedPrivateChatPeer` is only set on the success path.
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
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.presentsConversationListOnLaunch = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a persisted last-active DM peer is genuinely restorable at
|
||||
/// launch — validated against *durable* conversation 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 peerID is a geohash/Nostr DM (its stable Nostr
|
||||
/// identity is embedded in the id itself) or the peer is a persisted
|
||||
/// favorite (keychain-backed, stored by stable Noise public key across
|
||||
/// launches). Presence-independent and pure, so it is unit-testable.
|
||||
static func isDirectChatRestorable(
|
||||
_ peerID: PeerID,
|
||||
isPeerFavorited: (PeerID) -> Bool
|
||||
) -> Bool {
|
||||
if peerID.isGeoDM { return true }
|
||||
return isPeerFavorited(peerID)
|
||||
}
|
||||
|
||||
/// Production wiring of `isDirectChatRestorable`, extracted so the real
|
||||
/// favorites lookup (not just a stub predicate) is unit-testable via an
|
||||
/// injected in-memory-keychain-backed `FavoritesPersistenceService`.
|
||||
/// `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.
|
||||
static func isDirectChatRestorable(
|
||||
_ peerID: PeerID,
|
||||
favorites: FavoritesPersistenceService
|
||||
) -> Bool {
|
||||
isDirectChatRestorable(peerID, isPeerFavorited: {
|
||||
favorites.getFavoriteStatus(forPeerID: $0.toShort()) != nil
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
record(.openedURL(url.absoluteString))
|
||||
|
||||
|
||||
@ -339,6 +339,88 @@ 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"
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
init(storage: UserDefaults = .standard) {
|
||||
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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@ -488,6 +570,7 @@ final class ConversationStore: ObservableObject {
|
||||
activeChannel = channelID
|
||||
}
|
||||
refreshDerivedSelection()
|
||||
persistLastActive()
|
||||
}
|
||||
|
||||
/// Opens a private chat (`nil` closes it, returning the selection to the
|
||||
@ -497,6 +580,7 @@ final class ConversationStore: ObservableObject {
|
||||
selectedPrivatePeerID = peerID
|
||||
}
|
||||
refreshDerivedSelection()
|
||||
persistLastActive()
|
||||
}
|
||||
|
||||
private func refreshDerivedSelection() {
|
||||
|
||||
@ -186,6 +186,15 @@ struct ContentHeaderView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if case .mesh = locationChannelsModel.selectedChannel {
|
||||
Text(verbatim: "public · unencrypted")
|
||||
.bitchatFont(size: 10)
|
||||
.foregroundColor(palette.secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.accessibilityLabel(Text(verbatim: "public, unencrypted"))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar.toggle()
|
||||
|
||||
@ -102,10 +102,11 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
get: { showSidebar || selectedPrivatePeerID != nil },
|
||||
get: { showSidebar || selectedPrivatePeerID != nil || appChromeModel.presentsConversationListOnLaunch },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
showSidebar = false
|
||||
appChromeModel.presentsConversationListOnLaunch = false
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}
|
||||
|
||||
197
bitchatTests/ConversationStoreLastActiveTests.swift
Normal file
197
bitchatTests/ConversationStoreLastActiveTests.swift
Normal file
@ -0,0 +1,197 @@
|
||||
//
|
||||
// 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_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 })
|
||||
)
|
||||
}
|
||||
|
||||
func test_favoritePeerIsRestorable() {
|
||||
// A persisted favorite is stored by stable Noise public key and survives
|
||||
// restart — the one durable, presence-independent mesh relationship.
|
||||
XCTAssertTrue(
|
||||
AppRuntime.isDirectChatRestorable(peerID, isPeerFavorited: { _ in true })
|
||||
)
|
||||
}
|
||||
|
||||
func test_geoDMPeerIsRestorable_withoutFavorite() {
|
||||
// Geohash/Nostr DM ids embed a stable Nostr identity in the id itself,
|
||||
// so they are restorable even though they are not favorites.
|
||||
let geoDMPeer = PeerID(str: "nostr_0123456789abcdef")
|
||||
XCTAssertTrue(geoDMPeer.isGeoDM)
|
||||
XCTAssertTrue(
|
||||
AppRuntime.isDirectChatRestorable(geoDMPeer, isPeerFavorited: { _ 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 })
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(restored, .conversationList)
|
||||
}
|
||||
|
||||
// MARK: - Production wiring (real FavoritesPersistenceService)
|
||||
|
||||
func test_production_fullHexFavoritePeerIsRestorable() {
|
||||
// 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.
|
||||
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())
|
||||
XCTAssertFalse(fullHexPeer.isShort) // 64-hex, not the short form
|
||||
XCTAssertTrue(
|
||||
AppRuntime.isDirectChatRestorable(fullHexPeer, favorites: favorites)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
// 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