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 12adfda9..b7eab0bf 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1637,6 +1637,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 1fe95c75..6458aadf 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( @@ -541,9 +577,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..