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 index 9c62bcdd..ee61e018 100644 --- a/bitchat/Services/ComposerDraftStore.swift +++ b/bitchat/Services/ComposerDraftStore.swift @@ -9,23 +9,37 @@ import Foundation import BitFoundation -/// Persists unfinished composer text per conversation so switching mesh / +/// Holds unfinished composer text per conversation so switching mesh / /// geohash / DM channels does not silently discard what someone was typing. /// -/// Drafts are plain text in UserDefaults (same trust boundary as nickname and -/// theme). Panic wipe clears the whole map so a seized phone does not keep -/// half-written messages. +/// 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 { - static let storageKey = "composer.drafts.v1" - /// Cap each draft so a pasted novel cannot bloat preferences forever. + /// 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 keys fall off first. + /// 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) - case privatePeer(PeerID) + /// 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 { @@ -33,14 +47,21 @@ enum ComposerDraftStore { return "mesh" case .location(let geohash): return "geo:\(geohash.lowercased())" - case .privatePeer(let peerID): - return "dm:\(peerID.id)" + case .privateChat(let stableID): + return "dm:\(stableID.lowercased())" } } - static func from(peerID: PeerID?, channel: ChannelID) -> Key { + static func from( + peerID: PeerID?, + fingerprint: String?, + channel: ChannelID + ) -> Key { if let peerID { - return .privatePeer(peerID) + if let fingerprint, !fingerprint.isEmpty { + return .privateChat(stableID: fingerprint) + } + return .privateChat(stableID: peerID.id) } switch channel { case .mesh: @@ -51,42 +72,56 @@ enum ComposerDraftStore { } } - static func load(_ key: Key, in defaults: UserDefaults = .standard) -> String { - let map = readMap(in: defaults) - return map[key.storageString] ?? "" + static func load(_ key: Key) -> String { + lock.lock() + defer { lock.unlock() } + return entries[key.storageString]?.text ?? "" } - static func save(_ text: String, for key: Key, in defaults: UserDefaults = .standard) { - var map = readMap(in: defaults) + static func save(_ text: String, for key: Key) { + lock.lock() + defer { lock.unlock() } let trimmed = String(text.prefix(maxDraftLength)) if trimmed.isEmpty { - map.removeValue(forKey: key.storageString) + entries.removeValue(forKey: key.storageString) } else { - map[key.storageString] = trimmed - if map.count > maxDraftCount { - // Drop an arbitrary surplus key that is not the one just written. - let surplus = map.keys.filter { $0 != key.storageString }.prefix(map.count - maxDraftCount) - for doomed in surplus { - map.removeValue(forKey: doomed) - } - } + entries[key.storageString] = Entry(text: trimmed, updatedAt: Date()) + evictOldestIfNeededLocked() } - writeMap(map, in: defaults) } - static func reset(in defaults: UserDefaults = .standard) { - defaults.removeObject(forKey: storageKey) + static func reset() { + lock.lock() + defer { lock.unlock() } + entries.removeAll(keepingCapacity: false) } - private static func readMap(in defaults: UserDefaults) -> [String: String] { - defaults.dictionary(forKey: storageKey) as? [String: String] ?? [:] + /// 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 } - private static func writeMap(_ map: [String: String], in defaults: UserDefaults) { - if map.isEmpty { - defaults.removeObject(forKey: storageKey) - } else { - defaults.set(map, forKey: storageKey) + 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/Views/ContentView.swift b/bitchat/Views/ContentView.swift index f6fdf05b..6458aadf 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -251,6 +251,7 @@ struct ContentView: View { sharedContentImportModel.updateDestination(sharedContentDestination) activeDraftKey = ComposerDraftStore.Key.from( peerID: selectedPrivatePeerID, + fingerprint: selectedPrivatePeerID.flatMap { conversationUIModel.getFingerprint(for: $0) }, channel: locationChannelsModel.selectedChannel ) messageText = ComposerDraftStore.load(activeDraftKey) @@ -273,6 +274,7 @@ struct ContentView: View { sharedContentImportModel.updateDestination(sharedContentDestination) switchComposerDraft(to: ComposerDraftStore.Key.from( peerID: newValue, + fingerprint: newValue.flatMap { conversationUIModel.getFingerprint(for: $0) }, channel: locationChannelsModel.selectedChannel )) } @@ -283,16 +285,16 @@ struct ContentView: View { if selectedPrivatePeerID == nil { switchComposerDraft(to: ComposerDraftStore.Key.from( peerID: nil, + fingerprint: nil, channel: newChannel )) } } .onChange(of: scenePhase) { phase in if phase == .background || phase == .inactive { - // Skip persist when the composer was already cleared (e.g. - // panic wipe): otherwise a half-written message would be - // written back after ComposerDraftStore.reset(). - guard !messageText.isEmpty else { return } + // 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) } } diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift index 39436eee..26f59782 100644 --- a/bitchatTests/Services/ComposerDraftStoreTests.swift +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -4,76 +4,98 @@ import Testing import BitFoundation struct ComposerDraftStoreTests { - private func makeDefaults() -> UserDefaults { - let suite = "bitchat.tests.drafts.\(UUID().uuidString)" - return UserDefaults(suiteName: suite)! + /// 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() { - let defaults = makeDefaults() - ComposerDraftStore.save("hello", for: .mesh, in: defaults) - ComposerDraftStore.save(" ", for: .mesh, in: defaults) - // Whitespace-only is still a draft the user typed; empty string clears. - ComposerDraftStore.save("", for: .mesh, in: defaults) - #expect(ComposerDraftStore.load(.mesh, in: defaults).isEmpty) - #expect(defaults.object(forKey: ComposerDraftStore.storageKey) == nil) + @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() { - let defaults = makeDefaults() + @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") - ComposerDraftStore.save("mesh draft", for: .mesh, in: defaults) - ComposerDraftStore.save("geo draft", for: .location(geohash: "u4pruy"), in: defaults) - ComposerDraftStore.save("dm draft", for: .privatePeer(peer), in: defaults) - - #expect(ComposerDraftStore.load(.mesh, in: defaults) == "mesh draft") - #expect(ComposerDraftStore.load(.location(geohash: "u4pruy"), in: defaults) == "geo draft") - #expect(ComposerDraftStore.load(.privatePeer(peer), in: defaults) == "dm draft") - #expect(ComposerDraftStore.load(.location(geohash: "other"), in: defaults).isEmpty) - } - - @Test func geohashKeysAreCaseInsensitive() { - let defaults = makeDefaults() - ComposerDraftStore.save("city chat", for: .location(geohash: "U4PRUY"), in: defaults) - #expect(ComposerDraftStore.load(.location(geohash: "u4pruy"), in: defaults) == "city chat") - } - - @Test func keyFromPeerAndChannelPrefersPrivate() { - let peer = PeerID(str: "aabbccddeeff0011") - let key = ComposerDraftStore.Key.from( + let withFP = ComposerDraftStore.Key.from( peerID: peer, + fingerprint: "deadbeefcafebabe", channel: .location(GeohashChannel(level: .city, geohash: "u4pruy")) ) - #expect(key == .privatePeer(peer)) + #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() { - let defaults = makeDefaults() - let long = String(repeating: "a", count: ComposerDraftStore.maxDraftLength + 50) - ComposerDraftStore.save(long, for: .mesh, in: defaults) - #expect(ComposerDraftStore.load(.mesh, in: defaults).count == ComposerDraftStore.maxDraftLength) - } - - @Test func resetClearsAllDrafts() { - let defaults = makeDefaults() - ComposerDraftStore.save("keep quiet", for: .mesh, in: defaults) - ComposerDraftStore.reset(in: defaults) - #expect(ComposerDraftStore.load(.mesh, in: defaults).isEmpty) - #expect(defaults.object(forKey: ComposerDraftStore.storageKey) == nil) - } - - @Test func maxDraftCountEvictsSurplusKeys() { - let defaults = makeDefaults() - for index in 0..