Files
byre/web/src/drafts.ts
T
2026-10-16 08:42:48 -07:00

212 lines
5.5 KiB
TypeScript

import { findTask, onEach, state } from "./state.js";
type Saved = {
composer: Record<string, { text: string; images: number }>;
checked: Record<string, string[]>;
texts: Record<string, string>;
};
const composerTexts = new Map<string, string>();
const composerImages = new Map<string, number>();
type Send = { key: string; text: string; images: number };
const sending = new Set<Send>();
const lostImages = new Map<string, number>();
const checked = new Map<string, string[]>();
const texts = new Map<string, string>();
let storeKey: string | undefined;
let timer = 0;
export function loadDrafts(): void {
storeKey = `byre.drafts.${state.me!.login}`;
const saved = readSaved(storeKey);
for (const [key, d] of Object.entries(saved.composer)) {
if (d.text) {
composerTexts.set(key, d.text);
}
if (d.images > 0) {
lostImages.set(key, d.images);
}
}
for (const [id, opts] of Object.entries(saved.checked)) {
checked.set(id, opts);
}
for (const [id, text] of Object.entries(saved.texts)) {
texts.set(id, text);
}
prune();
onEach(["tasks", "inbox"], prune);
addEventListener("pagehide", flushDrafts);
}
function readSaved(key: string): Saved {
const out: Saved = { composer: {}, checked: {}, texts: {} };
let raw: unknown;
try {
raw = JSON.parse(localStorage.getItem(key) ?? "{}");
} catch (err) {
console.error("read drafts", err);
return out;
}
if (!isRecord(raw)) {
return out;
}
for (const [k, v] of Object.entries(isRecord(raw["composer"]) ? raw["composer"] : {})) {
if (isRecord(v) && typeof v["text"] === "string" && typeof v["images"] === "number") {
out.composer[k] = { text: v["text"], images: v["images"] };
}
}
for (const [k, v] of Object.entries(isRecord(raw["checked"]) ? raw["checked"] : {})) {
if (Array.isArray(v) && v.every((o) => typeof o === "string")) {
out.checked[k] = v as string[];
}
}
for (const [k, v] of Object.entries(isRecord(raw["texts"]) ? raw["texts"] : {})) {
if (typeof v === "string") {
out.texts[k] = v;
}
}
return out;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function prune(): void {
let changed = false;
for (const map of [composerTexts, composerImages, lostImages]) {
for (const key of map.keys()) {
const taskId = key.replace(/^message:/, "");
if (taskId !== "lead" && findTask(taskId)?.status !== "active") {
map.delete(key);
changed = true;
}
}
}
for (const map of [checked, texts]) {
for (const id of map.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
map.delete(id);
changed = true;
}
}
}
if (changed) {
save();
}
}
function save(): void {
clearTimeout(timer);
timer = setTimeout(flushDrafts, 400);
}
export function flushDrafts(): void {
clearTimeout(timer);
timer = 0;
if (!storeKey) {
return;
}
const out: Saved = { composer: {}, checked: Object.fromEntries(checked), texts: Object.fromEntries(texts) };
const keys = new Set([...composerTexts.keys(), ...composerImages.keys(), ...[...sending].map((s) => s.key), ...lostImages.keys()]);
for (const key of keys) {
const inFlight = [...sending].filter((s) => s.key === key);
const text = [...inFlight.map((s) => s.text), composerTexts.get(key) ?? ""].filter((t) => t).join("\n\n");
const images = (composerImages.get(key) ?? 0) + inFlight.reduce((n, s) => n + s.images, 0) + (lostImages.get(key) ?? 0);
if (text || images > 0) {
out.composer[key] = { text, images };
}
}
try {
localStorage.setItem(storeKey, JSON.stringify(out));
} catch (err) {
console.error("save drafts", err);
}
}
export function composerText(key: string): string {
return composerTexts.get(key) ?? "";
}
export function setComposerText(key: string, text: string): void {
if (text) {
composerTexts.set(key, text);
} else {
composerTexts.delete(key);
}
save();
}
export function setComposerImages(key: string, n: number): void {
if (n > 0) {
composerImages.set(key, n);
} else {
composerImages.delete(key);
}
save();
}
export function lostImageCount(key: string): number {
return lostImages.get(key) ?? 0;
}
export function forgetLostImages(key: string): void {
lostImages.delete(key);
save();
}
export function startSend(key: string, text: string, images: number): Send {
const send = { key, text, images };
sending.add(send);
composerTexts.delete(key);
return send;
}
export function endSend(send: Send, ok: boolean): void {
sending.delete(send);
if (ok) {
lostImages.delete(send.key);
} else {
const text = [send.text, composerTexts.get(send.key) ?? ""].filter((t) => t).join("\n\n");
if (text) {
composerTexts.set(send.key, text);
}
}
flushDrafts();
}
export function checkedAnswer(id: string): string[] {
return checked.get(id) ?? [];
}
export function setCheckedAnswer(id: string, options: string[]): void {
checked.set(id, options);
save();
}
export function hasTextAnswer(id: string): boolean {
return texts.has(id);
}
export function textAnswer(id: string): string | undefined {
return texts.get(id);
}
export function setTextAnswer(id: string, text: string): void {
texts.set(id, text);
save();
}
export function dropTextAnswer(id: string): void {
texts.delete(id);
save();
}
export function clearAnswer(id: string): void {
checked.delete(id);
texts.delete(id);
flushDrafts();
}