import Foundation import Observation private struct Saved: Codable { struct Composer: Codable { var text: String var images: Int } var composer: [String: Composer] = [:] var checked: [String: [String]] = [:] var texts: [String: String] = [:] } final class DraftSend { let key: String let text: String let images: Int init(key: String, text: String, images: Int) { self.key = key self.text = text self.images = images } } func draftOrigin(_ origin: URL) -> String { let c = URLComponents(url: origin, resolvingAgainstBaseURL: false)! let scheme = (c.scheme ?? "https").lowercased() return "\(scheme)://\((c.host ?? "").lowercased()):\(c.port ?? (scheme == "http" ? 80 : 443))" } @MainActor @Observable final class Drafts { private var composerTexts: [String: String] = [:] private var composerImages: [String: Int] = [:] private var sending: [DraftSend] = [] private var lostImages: [String: Int] = [:] private var checked: [String: [String]] = [:] private var texts: [String: String] = [:] @ObservationIgnored private let storeKey: String @ObservationIgnored private var timer: Task? init(origin: URL, login: String) { storeKey = "byre.drafts.\(draftOrigin(origin)).\(login)" let saved = Self.read(storeKey) for (key, d) in saved.composer { if !d.text.isEmpty { composerTexts[key] = d.text } if d.images > 0 { lostImages[key] = d.images } } checked = saved.checked texts = saved.texts } private static func read(_ key: String) -> Saved { guard let raw = UserDefaults.standard.string(forKey: key) else { return Saved() } do { return try JSONDecoder().decode(Saved.self, from: Data(raw.utf8)) } catch { log.error("read drafts: \(String(describing: error), privacy: .public)") return Saved() } } func prune(_ store: Store) { var changed = false for key in Set(composerTexts.keys).union(composerImages.keys).union(lostImages.keys) { let taskId = key.hasPrefix("message:") ? String(key.dropFirst("message:".count)) : key if taskId != "lead" && store.findTask(taskId)?.status != .active { composerTexts[key] = nil composerImages[key] = nil lostImages[key] = nil changed = true } } for id in Set(checked.keys).union(texts.keys) where !store.inbox.contains(where: { $0.id == id }) { checked[id] = nil texts[id] = nil changed = true } if changed { save() } } private func save() { timer?.cancel() timer = Task { [weak self] in try? await Task.sleep(for: .milliseconds(400)) if !Task.isCancelled { self?.flush() } } } func flush() { timer?.cancel() timer = nil var out = Saved(checked: checked, texts: texts) let keys = Set(composerTexts.keys).union(composerImages.keys).union(sending.map(\.key)).union(lostImages.keys) for key in keys { let inFlight = sending.filter { $0.key == key } let text = (inFlight.map(\.text) + [composerTexts[key] ?? ""]).filter { !$0.isEmpty }.joined(separator: "\n\n") let images = (composerImages[key] ?? 0) + inFlight.reduce(0) { $0 + $1.images } + (lostImages[key] ?? 0) if !text.isEmpty || images > 0 { out.composer[key] = Saved.Composer(text: text, images: images) } } do { UserDefaults.standard.set(String(decoding: try JSONEncoder().encode(out), as: UTF8.self), forKey: storeKey) } catch { log.error("save drafts: \(String(describing: error), privacy: .public)") } } func composerText(_ key: String) -> String { composerTexts[key] ?? "" } func setComposerText(_ key: String, _ text: String) { if (composerTexts[key] ?? "") == text { return } composerTexts[key] = text.isEmpty ? nil : text save() } func setComposerImages(_ key: String, _ n: Int) { if (composerImages[key] ?? 0) == n { return } composerImages[key] = n > 0 ? n : nil save() } func lostImageCount(_ key: String) -> Int { lostImages[key] ?? 0 } func forgetLostImages(_ key: String) { lostImages[key] = nil save() } func startSend(_ key: String, _ text: String, _ images: Int) -> DraftSend { let send = DraftSend(key: key, text: text, images: images) sending.append(send) composerTexts[key] = nil return send } func endSend(_ send: DraftSend, _ ok: Bool) { sending.removeAll { $0 === send } if ok { lostImages[send.key] = nil } else { let text = [send.text, composerTexts[send.key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n") if !text.isEmpty { composerTexts[send.key] = text } } flush() } func checkedAnswer(_ id: String) -> [String] { checked[id] ?? [] } func setCheckedAnswer(_ id: String, _ options: [String]) { checked[id] = options save() } func hasTextAnswer(_ id: String) -> Bool { texts[id] != nil } func textAnswer(_ id: String) -> String? { texts[id] } func setTextAnswer(_ id: String, _ text: String) { texts[id] = text save() } func dropTextAnswer(_ id: String) { texts[id] = nil save() } func clearAnswer(_ id: String) { checked[id] = nil texts[id] = nil flush() } }