mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Merge b9e15c233d0c5e99f76e5c7bbdb11ed3f60846a0 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
d59301e8ca
@ -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)
|
||||
}
|
||||
|
||||
127
bitchat/Services/ComposerDraftStore.swift
Normal file
127
bitchat/Services/ComposerDraftStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
102
bitchatTests/Services/ComposerDraftStoreTests.swift
Normal file
102
bitchatTests/Services/ComposerDraftStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user