From 5a890559c185bcc181489ee8fe87cb9242a9a1b9 Mon Sep 17 00:00:00 2001 From: ecgang Date: Sun, 5 Jul 2026 17:25:24 -0700 Subject: [PATCH] feat: restore last-active conversation on launch; label mesh as public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bitchat/App/AppChromeModel.swift | 4 + bitchat/App/AppRuntime.swift | 81 +++++++ bitchat/App/ConversationStore.swift | 84 ++++++++ bitchat/Views/ContentHeaderView.swift | 9 + bitchat/Views/ContentView.swift | 3 +- .../ConversationStoreLastActiveTests.swift | 197 ++++++++++++++++++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 bitchatTests/ConversationStoreLastActiveTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 3f9cacd7..ea4e86df 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -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() diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index a706cfac..e37eb8c1 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -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)) diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 88d72085..f1bcc872 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -339,6 +339,88 @@ final class ConversationStore: ObservableObject { let changes = PassthroughSubject() + // 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() { diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index eb188ed7..fe027b67 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -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() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 86bb31ad..49d4bae8 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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() } } diff --git a/bitchatTests/ConversationStoreLastActiveTests.swift b/bitchatTests/ConversationStoreLastActiveTests.swift new file mode 100644 index 00000000..292947fd --- /dev/null +++ b/bitchatTests/ConversationStoreLastActiveTests.swift @@ -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 +// + +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 + } +}