Files
byre/mac/Sources/Byre/Drafts.swift
T

205 lines
5.9 KiB
Swift
Raw Normal View History

2026-10-16 23:30:57 -07:00
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<Void, Never>?
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()
}
}