import { SvelteMap } from 'svelte/reactivity'; import { marker, withoutMarkers, type Attached } from './attachments'; // What was typed in a chat's composer and not yet sent. The composer is // destroyed with its window when another board is shown or the page goes, so // a draft lives here, by session, and in localStorage. Only the text is kept // there: images are too big for it, so a reload drops them and their markers. const KEY = 'albedo.draft.v1:'; /** A draft untouched this long is not worth keeping; the session may be gone. */ const STALE_MS = 30 * 24 * 60 * 60 * 1000; const SAVE_AFTER_MS = 300; function read(session: string): string { try { const saved = JSON.parse(localStorage.getItem(KEY + session) ?? ''); return typeof saved?.text === 'string' ? withoutMarkers(saved.text) : ''; } catch { return ''; } } /** Drops the saved drafts nobody came back to. */ function prune() { try { for (const key of Object.keys(localStorage).filter((name) => name.startsWith(KEY))) { const saved = JSON.parse(localStorage.getItem(key) ?? 'null'); if (!(Date.now() - saved?.at < STALE_MS)) localStorage.removeItem(key); } } catch {} } const unsaved = new Set(); let timer: ReturnType | undefined; function flush() { clearTimeout(timer); timer = undefined; for (const draft of unsaved) draft.save(); unsaved.clear(); } export class Draft { readonly images = new SvelteMap(); /** The last image marker handed out. */ next = 0; #text: string; #session: string; constructor(session: string) { this.#session = session; this.#text = read(session); } get text(): string { return this.#text; } /** Setting the text saves it a moment later, once typing pauses. */ set text(value: string) { if (value === this.#text) return; this.#text = value; unsaved.add(this); timer ??= setTimeout(flush, SAVE_AFTER_MS); } /** Shows the draft's text in a composer that is open; set by it while it is. */ show: ((text: string) => void) | null = null; /** Appends cancelled messages after what is typed, each a paragraph, their images renumbered after the draft's. */ restore(sends: { text: string; images: Attached[] }[]) { const parts = this.#text.trim() ? [this.#text.trimEnd()] : []; for (const send of sends) { const offset = this.next; send.images.forEach((image, index) => this.images.set(offset + index + 1, image)); this.next += send.images.length; parts.push(send.text.replace(/\[Image #(\d+)\]/g, (_, n) => marker(Number(n) + offset))); } this.text = parts.join('\n\n'); this.show?.(this.#text); } save() { try { const text = withoutMarkers(this.#text); if (text.trim()) localStorage.setItem(KEY + this.#session, JSON.stringify({ text, at: Date.now() })); else localStorage.removeItem(KEY + this.#session); } catch {} } } const drafts = new Map(); export function draftOf(session: string): Draft { let draft = drafts.get(session); if (!draft) drafts.set(session, (draft = new Draft(session))); return draft; } if (typeof window !== 'undefined') { prune(); // The last words typed before a reload or a closed tab are still waiting on the timer. window.addEventListener('pagehide', flush); }