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

243 lines
6.9 KiB
Swift
Raw Normal View History

2026-10-16 23:30:57 -07:00
import Foundation
import Observation
import SwiftData
2026-10-16 23:30:57 -07:00
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 scope: String
2026-10-16 23:30:57 -07:00
@ObservationIgnored private var timer: Task<Void, Never>?
@ObservationIgnored private var dirty = false
2026-10-16 23:30:57 -07:00
init(origin: URL, login: String) {
scope = "\(draftOrigin(origin)).\(login)"
load(Self.read(scope))
}
private func load(_ saved: Saved) {
composerTexts = saved.composer.filter { !$0.value.text.isEmpty }.mapValues(\.text)
lostImages = saved.composer.reduce(into: [:]) { lost, d in
let n = d.value.images - (composerImages[d.key] ?? 0)
if n > 0 {
lost[d.key] = n
2026-10-16 23:30:57 -07:00
}
}
checked = saved.checked
texts = saved.texts
}
func reload() {
if !dirty && sending.isEmpty {
load(Self.read(scope))
}
}
private static func records(_ scope: String) -> [DraftRecord] {
fetchRecords(FetchDescriptor(predicate: #Predicate { $0.scope == scope }, sortBy: [SortDescriptor(\.saved, order: .reverse)]))
}
private static func read(_ scope: String) -> Saved {
guard let record = records(scope).first else {
2026-10-16 23:30:57 -07:00
return Saved()
}
do {
return try JSONDecoder().decode(Saved.self, from: record.json)
2026-10-16 23:30:57 -07:00
} 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() {
dirty = true
2026-10-16 23:30:57 -07:00
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
guard dirty else {
return
}
dirty = false
2026-10-16 23:30:57 -07:00
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 {
let json = try JSONEncoder().encode(out)
let records = Self.records(scope)
if records.isEmpty {
persistence.insert(DraftRecord(scope: scope, json: json))
} else {
records[0].json = json
records[0].saved = Date()
for record in records.dropFirst() {
persistence.delete(record)
}
}
saveRecords()
2026-10-16 23:30:57 -07:00
} 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
dirty = true
2026-10-16 23:30:57 -07:00
return send
}
func endSend(_ send: DraftSend, _ ok: Bool) {
sending.removeAll { $0 === send }
if ok {
lostImages[send.key] = nil
} else {
restoreText(send.key, send.text)
2026-10-16 23:30:57 -07:00
}
dirty = true
2026-10-16 23:30:57 -07:00
flush()
}
func restoreText(_ key: String, _ text: String) {
let joined = [text, composerTexts[key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n")
if !joined.isEmpty {
composerTexts[key] = joined
}
save()
}
2026-10-16 23:30:57 -07:00
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
dirty = true
2026-10-16 23:30:57 -07:00
flush()
}
}