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:
ecgang 2026-07-05 17:25:24 -07:00
parent 3ea4188699
commit 5a890559c1
6 changed files with 377 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}