Merge b9e15c233d0c5e99f76e5c7bbdb11ed3f60846a0 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Taksh Kothari 2026-08-06 01:44:42 +00:00 committed by GitHub
commit d59301e8ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 279 additions and 1 deletions

View File

@ -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)
}

View File

@ -0,0 +1,127 @@
//
// ComposerDraftStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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..<ComposerDraftStore.maxDraftCount {
ComposerDraftStore.save(
"d\(index)",
for: .location(geohash: String(format: "gh%04d", index))
)
}
ComposerDraftStore.save("newest", for: .mesh)
#expect(ComposerDraftStore.countForTesting() == ComposerDraftStore.maxDraftCount)
#expect(ComposerDraftStore.load(.mesh) == "newest")
}
}
@Test func clearingDraftRemovesKeySoItDoesNotResurrect() throws {
try withCleanStore {
ComposerDraftStore.save("old text", for: .mesh)
ComposerDraftStore.save("", for: .mesh)
#expect(ComposerDraftStore.load(.mesh).isEmpty)
}
}
}