From e62fa0a1c472c88b5e04bbdd82612eeec0c391b6 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:43:57 +0300 Subject: [PATCH 01/10] 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. --- bitchat/Services/ComposerDraftStore.swift | 91 +++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 bitchat/Services/ComposerDraftStore.swift diff --git a/bitchat/Services/ComposerDraftStore.swift b/bitchat/Services/ComposerDraftStore.swift new file mode 100644 index 00000000..871a9bca --- /dev/null +++ b/bitchat/Services/ComposerDraftStore.swift @@ -0,0 +1,91 @@ +// +// ComposerDraftStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Persists 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. +enum ComposerDraftStore { + static let storageKey = "composer.drafts.v1" + /// Cap each draft so a pasted novel cannot bloat preferences forever. + static let maxDraftLength = 8_000 + /// Cap how many conversations keep a draft; oldest keys fall off first. + static let maxDraftCount = 64 + + enum Key: Hashable, Equatable { + case mesh + case location(geohash: String) + case privatePeer(PeerID) + + var storageString: String { + switch self { + case .mesh: + return "mesh" + case .location(let geohash): + return "geo:\(geohash.lowercased())" + case .privatePeer(let peerID): + return "dm:\(peerID.id)" + } + } + + static func from(peerID: PeerID?, channel: ChannelID) -> Key { + if let peerID { + return .privatePeer(peerID) + } + switch channel { + case .mesh: + return .mesh + case .location(let ch): + return .location(geohash: ch.geohash) + } + } + } + + static func load(_ key: Key, in defaults: UserDefaults = .standard) -> String { + let map = readMap(in: defaults) + return map[key.storageString] ?? "" + } + + static func save(_ text: String, for key: Key, in defaults: UserDefaults = .standard) { + var map = readMap(in: defaults) + let trimmed = String(text.prefix(maxDraftLength)) + if trimmed.isEmpty { + map.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) + } + } + } + writeMap(map, in: defaults) + } + + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: storageKey) + } + + private static func readMap(in defaults: UserDefaults) -> [String: String] { + defaults.dictionary(forKey: storageKey) as? [String: String] ?? [:] + } + + private static func writeMap(_ map: [String: String], in defaults: UserDefaults) { + if map.isEmpty { + defaults.removeObject(forKey: storageKey) + } else { + defaults.set(map, forKey: storageKey) + } + } +} From 371e77ac8a26fde5b60c513c27e8fd429748b109 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:43:57 +0300 Subject: [PATCH 02/10] 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. --- bitchat/ViewModels/ChatViewModel.swift | 1 + bitchat/Views/ContentView.swift | 35 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) 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..fb77e698 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 @@ -241,6 +244,11 @@ struct ContentView: View { } #endif sharedContentImportModel.updateDestination(sharedContentDestination) + activeDraftKey = ComposerDraftStore.Key.from( + peerID: selectedPrivatePeerID, + channel: locationChannelsModel.selectedChannel + ) + messageText = ComposerDraftStore.load(activeDraftKey) } .onChange(of: colorScheme) { newValue in conversationUIModel.setCurrentColorScheme(newValue) @@ -258,9 +266,26 @@ struct ContentView: View { showSidebar = true } sharedContentImportModel.updateDestination(sharedContentDestination) + switchComposerDraft(to: ComposerDraftStore.Key.from( + peerID: newValue, + 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, + channel: newChannel + )) + } + } + .onChange(of: scenePhase) { phase in + if phase == .background || phase == .inactive { + ComposerDraftStore.save(messageText, for: activeDraftKey) + } } .sheet( isPresented: Binding( @@ -541,9 +566,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) + } } From 8090d9421d08fa2f9cb738e72422f12f7cb48db2 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:43:57 +0300 Subject: [PATCH 03/10] test: cover ComposerDraftStore isolation, truncation, and reset Pinned cases for mesh/geo/DM keys and the panic-wipe clear path. --- .../Services/ComposerDraftStoreTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 bitchatTests/Services/ComposerDraftStoreTests.swift diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift new file mode 100644 index 00000000..e318a7e0 --- /dev/null +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import bitchat +import BitFoundation + +struct ComposerDraftStoreTests { + private func makeDefaults() -> UserDefaults { + let suite = "bitchat.tests.drafts.\(UUID().uuidString)" + return UserDefaults(suiteName: suite)! + } + + @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 draftsAreIsolatedPerConversation() { + let defaults = makeDefaults() + 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( + peerID: peer, + channel: .location(GeohashChannel(level: .city, geohash: "u4pruy")) + ) + #expect(key == .privatePeer(peer)) + } + + @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) + } +} From c887c313cd6f28cd66e3d68c1880f04cb5d95f16 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:44:52 +0300 Subject: [PATCH 04/10] test: assert composer draft map stays within maxDraftCount Newest keys win when the store is already full so prefs cannot grow without bound. --- bitchatTests/Services/ComposerDraftStoreTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift index e318a7e0..4677f263 100644 --- a/bitchatTests/Services/ComposerDraftStoreTests.swift +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -62,3 +62,15 @@ struct ComposerDraftStoreTests { #expect(defaults.object(forKey: ComposerDraftStore.storageKey) == nil) } } + + @Test func maxDraftCountEvictsSurplusKeys() { + let defaults = makeDefaults() + for index in 0.. Date: Fri, 31 Jul 2026 11:49:26 +0300 Subject: [PATCH 05/10] 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. --- bitchat/Services/ComposerDraftStore.swift | 1 + bitchatTests/Services/ComposerDraftStoreTests.swift | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bitchat/Services/ComposerDraftStore.swift b/bitchat/Services/ComposerDraftStore.swift index 871a9bca..9c62bcdd 100644 --- a/bitchat/Services/ComposerDraftStore.swift +++ b/bitchat/Services/ComposerDraftStore.swift @@ -7,6 +7,7 @@ // import Foundation +import BitFoundation /// Persists unfinished composer text per conversation so switching mesh / /// geohash / DM channels does not silently discard what someone was typing. diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift index 4677f263..39436eee 100644 --- a/bitchatTests/Services/ComposerDraftStoreTests.swift +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -61,12 +61,15 @@ struct ComposerDraftStoreTests { #expect(ComposerDraftStore.load(.mesh, in: defaults).isEmpty) #expect(defaults.object(forKey: ComposerDraftStore.storageKey) == nil) } -} @Test func maxDraftCountEvictsSurplusKeys() { let defaults = makeDefaults() for index in 0.. Date: Fri, 31 Jul 2026 14:11:36 +0300 Subject: [PATCH 06/10] 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. --- bitchat/Views/ContentView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index fb77e698..f6fdf05b 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -236,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 { @@ -284,6 +289,10 @@ struct ContentView: View { } .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 } ComposerDraftStore.save(messageText, for: activeDraftKey) } } From 3ac4b4232c06624f4872b92979b4549f0113c6c8 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 17:57:24 +0300 Subject: [PATCH 07/10] Address Jack review: keep composer drafts in memory only (#1569). --- bitchat/App/ConversationUIModel.swift | 4 + bitchat/Services/ComposerDraftStore.swift | 107 ++++++++----- bitchat/Views/ContentView.swift | 10 +- .../Services/ComposerDraftStoreTests.swift | 142 ++++++++++-------- 4 files changed, 163 insertions(+), 100 deletions(-) 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.. Date: Mon, 3 Aug 2026 17:49:32 +0530 Subject: [PATCH 10/10] test: serialize ComposerDraftStoreTests The in-memory draft map is process-global; parallel tests were racing replaceAllForTesting and under-counting during eviction checks. --- bitchatTests/Services/ComposerDraftStoreTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/bitchatTests/Services/ComposerDraftStoreTests.swift b/bitchatTests/Services/ComposerDraftStoreTests.swift index 26f59782..ff071ae5 100644 --- a/bitchatTests/Services/ComposerDraftStoreTests.swift +++ b/bitchatTests/Services/ComposerDraftStoreTests.swift @@ -3,6 +3,7 @@ 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 {