From 5e9287fae1e5fea80ca741d4ea669829dc16f144 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Thu, 24 Sep 2026 13:05:50 +0530 Subject: [PATCH] Keep composer drafts across mesh, geohash, and DM switches (#1569) * feat: add ComposerDraftStore for per-conversation drafts Keep unfinished composer text keyed by mesh, geohash, or DM peer so channel switches stop silently discarding what someone was typing. * feat: restore composer drafts across channel and DM switches Save on leave and background, reload on enter, clear after send, and wipe drafts during panic clear. * test: cover ComposerDraftStore isolation, truncation, and reset Pinned cases for mesh/geo/DM keys and the panic-wipe clear path. * test: assert composer draft map stays within maxDraftCount Newest keys win when the store is already full so prefs cannot grow without bound. * fix: import BitFoundation so ComposerDraftStore sees PeerID iOS target build failed without the module import that SPM resolved transitively but Xcode did not. Also keep the eviction test inside the test suite type. * fix: clear in-memory composer draft on panic wipe ComposerDraftStore.reset() alone left messageText in ContentView, so the next background transition could persist the pre-wipe draft again. * Address Jack review: keep composer drafts in memory only (#1569). * ci: retrigger after unrelated SimulatedMesh flake * ci: retrigger checks after simulator flake * test: serialize ComposerDraftStoreTests The in-memory draft map is process-global; parallel tests were racing replaceAllForTesting and under-counting during eviction checks. --- bitchat/App/ConversationUIModel.swift | 4 + bitchat/Services/ComposerDraftStore.swift | 127 ++++++++++++++++++ bitchat/ViewModels/ChatViewModel.swift | 1 + bitchat/Views/ContentView.swift | 46 ++++++- .../Services/ComposerDraftStoreTests.swift | 102 ++++++++++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 bitchat/Services/ComposerDraftStore.swift create mode 100644 bitchatTests/Services/ComposerDraftStoreTests.swift diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 4883a04c..131177b1 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -98,6 +98,10 @@ final class ConversationUIModel: ObservableObject { chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName) } + func getFingerprint(for peerID: PeerID) -> String? { + chatViewModel.getFingerprint(for: peerID) + } + func updateAutocomplete(for text: String, cursorPosition: Int) { chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) } diff --git a/bitchat/Services/ComposerDraftStore.swift b/bitchat/Services/ComposerDraftStore.swift new file mode 100644 index 00000000..ee61e018 --- /dev/null +++ b/bitchat/Services/ComposerDraftStore.swift @@ -0,0 +1,127 @@ +// +// ComposerDraftStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import BitFoundation + +/// Holds unfinished composer text per conversation so switching mesh / +/// geohash / DM channels does not silently discard what someone was typing. +/// +/// Drafts stay **in memory only** — message content must not land in +/// UserDefaults (see MessageOutboxStore: only the sealed outbox persists +/// plaintext). Losing a draft on process death is acceptable; surviving a +/// channel/DM switch is the value. +/// +/// Panic wipe clears the whole map so a seized phone does not keep +/// half-written messages in RAM either. +enum ComposerDraftStore { + /// Cap each draft so a pasted novel cannot bloat the map forever. + static let maxDraftLength = 8_000 + /// Cap how many conversations keep a draft; oldest entries fall off first. + static let maxDraftCount = 64 + + private struct Entry { + var text: String + var updatedAt: Date + } + + private static var entries: [String: Entry] = [:] + private static let lock = NSLock() + + enum Key: Hashable, Equatable { + case mesh + case location(geohash: String) + /// Mesh DM keyed by Noise fingerprint when known; falls back to the + /// current peerID only before handshake so drafts do not orphan on + /// peerID rotation mid-session. + case privateChat(stableID: String) + + var storageString: String { + switch self { + case .mesh: + return "mesh" + case .location(let geohash): + return "geo:\(geohash.lowercased())" + case .privateChat(let stableID): + return "dm:\(stableID.lowercased())" + } + } + + static func from( + peerID: PeerID?, + fingerprint: String?, + channel: ChannelID + ) -> Key { + if let peerID { + if let fingerprint, !fingerprint.isEmpty { + return .privateChat(stableID: fingerprint) + } + return .privateChat(stableID: peerID.id) + } + switch channel { + case .mesh: + return .mesh + case .location(let ch): + return .location(geohash: ch.geohash) + } + } + } + + static func load(_ key: Key) -> String { + lock.lock() + defer { lock.unlock() } + return entries[key.storageString]?.text ?? "" + } + + static func save(_ text: String, for key: Key) { + lock.lock() + defer { lock.unlock() } + let trimmed = String(text.prefix(maxDraftLength)) + if trimmed.isEmpty { + entries.removeValue(forKey: key.storageString) + } else { + entries[key.storageString] = Entry(text: trimmed, updatedAt: Date()) + evictOldestIfNeededLocked() + } + } + + static func reset() { + lock.lock() + defer { lock.unlock() } + entries.removeAll(keepingCapacity: false) + } + + /// Test helper: replace the in-memory map (and return the previous one). + @discardableResult + static func replaceAllForTesting(_ newEntries: [String: String] = [:]) -> [String: String] { + lock.lock() + defer { lock.unlock() } + let previous = entries.mapValues(\.text) + let now = Date() + entries = Dictionary(uniqueKeysWithValues: newEntries.map { ($0.key, Entry(text: $0.value, updatedAt: now)) }) + return previous + } + + static func countForTesting() -> Int { + lock.lock() + defer { lock.unlock() } + return entries.count + } + + private static func evictOldestIfNeededLocked() { + guard entries.count > maxDraftCount else { return } + let surplus = entries.count - maxDraftCount + let doomed = entries + .sorted { $0.value.updatedAt < $1.value.updatedAt } + .prefix(surplus) + .map(\.key) + for key in doomed { + entries.removeValue(forKey: key) + } + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 63fba0f8..9e010dbd 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1641,6 +1641,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage MeshSightingsTracker.shared.clear() MeshEchoSettings.reset() NotificationPrivacySettings.reset() + ComposerDraftStore.reset() // A hand-added relay names an operator someone chose to route through, // which is the kind of trace a wipe should not leave behind. NostrRelaySettings.reset() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index db7b0712..7c07d546 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -98,6 +98,9 @@ struct ContentView: View { @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @State private var messageText = "" + /// Conversation the current `messageText` belongs to, so a channel switch + /// can save under the previous key before loading the next draft. + @State private var activeDraftKey: ComposerDraftStore.Key = .mesh @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @Environment(\.appTheme) private var appTheme @@ -233,6 +236,11 @@ struct ContentView: View { } appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in voiceRecordingVM?.panicWipe() + // Drop in-memory composer text before ChatViewModel resets + // persisted drafts — otherwise the next inactive/background + // transition would write the pre-wipe draft back. + messageText = "" + activeDraftKey = .mesh } #if os(macOS) DispatchQueue.main.async { @@ -241,6 +249,12 @@ struct ContentView: View { } #endif sharedContentImportModel.updateDestination(sharedContentDestination) + activeDraftKey = ComposerDraftStore.Key.from( + peerID: selectedPrivatePeerID, + fingerprint: selectedPrivatePeerID.flatMap { conversationUIModel.getFingerprint(for: $0) }, + channel: locationChannelsModel.selectedChannel + ) + messageText = ComposerDraftStore.load(activeDraftKey) } .onChange(of: colorScheme) { newValue in conversationUIModel.setCurrentColorScheme(newValue) @@ -258,9 +272,31 @@ struct ContentView: View { showSidebar = true } sharedContentImportModel.updateDestination(sharedContentDestination) + switchComposerDraft(to: ComposerDraftStore.Key.from( + peerID: newValue, + fingerprint: newValue.flatMap { conversationUIModel.getFingerprint(for: $0) }, + channel: locationChannelsModel.selectedChannel + )) } - .onChange(of: locationChannelsModel.selectedChannel) { _ in + .onChange(of: locationChannelsModel.selectedChannel) { newChannel in sharedContentImportModel.updateDestination(sharedContentDestination) + // Private drafts are keyed by peer; only public channel switches + // need a draft swap while no DM is open. + if selectedPrivatePeerID == nil { + switchComposerDraft(to: ComposerDraftStore.Key.from( + peerID: nil, + fingerprint: nil, + channel: newChannel + )) + } + } + .onChange(of: scenePhase) { phase in + if phase == .background || phase == .inactive { + // Always save, including empty — clearing the composer must + // remove the in-memory draft so it does not resurrect on the + // next switch back into this conversation. + ComposerDraftStore.save(messageText, for: activeDraftKey) + } } .sheet( isPresented: Binding( @@ -577,9 +613,17 @@ struct ContentView: View { guard let trimmed = messageText.trimmedOrNilIfEmpty else { return } messageText = "" + ComposerDraftStore.save("", for: activeDraftKey) DispatchQueue.main.async { self.conversationUIModel.sendMessage(trimmed) } } + + private func switchComposerDraft(to newKey: ComposerDraftStore.Key) { + guard newKey != activeDraftKey else { return } + ComposerDraftStore.save(messageText, for: activeDraftKey) + activeDraftKey = newKey + messageText = ComposerDraftStore.load(newKey) + } } diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift new file mode 100644 index 00000000..ff071ae5 --- /dev/null +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import bitchat +import BitFoundation + +@Suite(.serialized) +struct ComposerDraftStoreTests { + /// Isolate each test from leftover in-memory drafts. + private func withCleanStore(_ body: () throws -> Void) rethrows { + ComposerDraftStore.replaceAllForTesting([:]) + defer { ComposerDraftStore.replaceAllForTesting([:]) } + try body() + } + + @Test func emptyDraftIsNotStored() throws { + try withCleanStore { + ComposerDraftStore.save("hello", for: .mesh) + ComposerDraftStore.save(" ", for: .mesh) + // Whitespace-only is still a draft the user typed; empty string clears. + ComposerDraftStore.save("", for: .mesh) + #expect(ComposerDraftStore.load(.mesh).isEmpty) + #expect(ComposerDraftStore.countForTesting() == 0) + } + } + + @Test func draftsAreIsolatedPerConversation() throws { + try withCleanStore { + let peer = PeerID(str: "aabbccddeeff0011") + ComposerDraftStore.save("mesh draft", for: .mesh) + ComposerDraftStore.save("geo draft", for: .location(geohash: "u4pruy")) + ComposerDraftStore.save("dm draft", for: .privateChat(stableID: peer.id)) + + #expect(ComposerDraftStore.load(.mesh) == "mesh draft") + #expect(ComposerDraftStore.load(.location(geohash: "u4pruy")) == "geo draft") + #expect(ComposerDraftStore.load(.privateChat(stableID: peer.id)) == "dm draft") + #expect(ComposerDraftStore.load(.location(geohash: "other")).isEmpty) + } + } + + @Test func geohashKeysAreCaseInsensitive() throws { + try withCleanStore { + ComposerDraftStore.save("city chat", for: .location(geohash: "U4PRUY")) + #expect(ComposerDraftStore.load(.location(geohash: "u4pruy")) == "city chat") + } + } + + @Test func keyFromPeerPrefersFingerprintWhenPresent() { + let peer = PeerID(str: "aabbccddeeff0011") + let withFP = ComposerDraftStore.Key.from( + peerID: peer, + fingerprint: "deadbeefcafebabe", + channel: .location(GeohashChannel(level: .city, geohash: "u4pruy")) + ) + #expect(withFP == .privateChat(stableID: "deadbeefcafebabe")) + + let withoutFP = ComposerDraftStore.Key.from( + peerID: peer, + fingerprint: nil, + channel: .mesh + ) + #expect(withoutFP == .privateChat(stableID: peer.id)) + } + + @Test func longDraftsAreTruncated() throws { + try withCleanStore { + let long = String(repeating: "a", count: ComposerDraftStore.maxDraftLength + 50) + ComposerDraftStore.save(long, for: .mesh) + #expect(ComposerDraftStore.load(.mesh).count == ComposerDraftStore.maxDraftLength) + } + } + + @Test func resetClearsAllDrafts() throws { + try withCleanStore { + ComposerDraftStore.save("keep quiet", for: .mesh) + ComposerDraftStore.reset() + #expect(ComposerDraftStore.load(.mesh).isEmpty) + #expect(ComposerDraftStore.countForTesting() == 0) + } + } + + @Test func maxDraftCountEvictsOldestKeys() throws { + try withCleanStore { + for index in 0..