From 6c1399f88fb859e39f2b3e246fa370f59ac0f447 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 14:18:13 +0100 Subject: [PATCH] Keep composer drafts across reloads and preview attached images The composer's unsent text and uploaded files are kept in localStorage per bot and workspace until sent. Picked images show a small preview in place of their icon. Co-Authored-By: Claude Opus 5.5 --- src/data/app-data.ts | 5 ++ .../(hooks)/use-composer-attachments.ts | 67 +++++++++++++++++-- .../[bot_id]/(hooks)/use-composer-draft.ts | 41 ++++++++++++ .../(partials)/composer-attachment-chip.tsx | 13 +++- .../composer-attachment-preview.tsx | 18 +++++ .../(partials)/composer-attachments.tsx | 1 + .../bots/[bot_id]/(partials)/composer.tsx | 8 ++- .../[bot_id]/(utils)/composer-draft-key.ts | 14 ++++ 8 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-preview.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(utils)/composer-draft-key.ts diff --git a/src/data/app-data.ts b/src/data/app-data.ts index b9c0a7b..7af7918 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -6,6 +6,11 @@ export const AppData = { * Shared with twui's `ColorSchemeSelector`, so it must stay `theme`. */ ThemeStorageKey: "theme", + /** + * Prefix of the composer's unsent drafts in `localStorage`, one per + * bot and workspace. + */ + ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`, ServerHealthMessage: "Server Running Well", AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`, AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`, diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-attachments.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-attachments.ts index b84a796..a06e4cc 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-attachments.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-attachments.ts @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AppData } from "@/src/data/app-data"; import type { BotMessageAttachment } from "@/src/types"; import uploadBotAttachment from "../(functions)/upload-bot-attachment"; +import composerDraftKey from "../(utils)/composer-draft-key"; export type PendingAttachment = { key: string; @@ -9,14 +10,59 @@ export type PendingAttachment = { status: "uploading" | "ready" | "failed"; attachment?: BotMessageAttachment; error?: string; + /** + * A local object URL of a picked image, shown while it uploads and + * after, so its preview never waits for the server. + */ + preview_url?: string; +}; + +type Params = { + bot_id?: number | "" | null; + workspace_id?: number | null; }; /** * The files picked for the next message: each uploads as soon as it's - * added, so sending only waits for ones still going up. + * added, so sending only waits for ones still going up. Uploaded ones are + * kept in `localStorage` per bot and workspace until sent, like the text + * (`useComposerDraft`), and come back after a reload. */ -export default function useComposerAttachments() { +export default function useComposerAttachments({ bot_id, workspace_id }: Params) { + const storage_key = composerDraftKey({ bot_id, workspace_id, part: "files" }); const [pending, setPending] = useState([]); + const [loaded_key, setLoadedKey] = useState(); + + useEffect(() => { + let saved: BotMessageAttachment[] = []; + + try { + saved = JSON.parse(localStorage.getItem(storage_key) || "[]"); + } catch {} + + setPending( + (Array.isArray(saved) ? saved : []).map((attachment) => ({ + key: `saved-${attachment.id}`, + name: attachment.name, + status: "ready" as const, + attachment, + })), + ); + setLoadedKey(storage_key); + }, [storage_key]); + + useEffect(() => { + if (loaded_key != storage_key) return; + + const ready = pending.flatMap((item) => + item.status == "ready" && item.attachment ? [item.attachment] : [], + ); + + try { + if (ready.length) localStorage.setItem(storage_key, JSON.stringify(ready)); + else localStorage.removeItem(storage_key); + } catch {} + }, [pending, loaded_key]); const update = ({ key, ...fields }: Partial & { key: string }) => setPending((prev) => prev.map((item) => (item.key == key ? { ...item, ...fields } : item))); @@ -31,6 +77,9 @@ export default function useComposerAttachments() { key: `${Date.now()}-${Math.random().toString(36).slice(2)}`, name: file.name || "file", status: "uploading" as const, + preview_url: file.type.startsWith("image/") + ? URL.createObjectURL(file) + : undefined, }, })); @@ -47,14 +96,22 @@ export default function useComposerAttachments() { } }; - const remove = ({ key }: { key: string }) => + const revoke = ({ items }: { items: PendingAttachment[] }) => + items.forEach((item) => item.preview_url && URL.revokeObjectURL(item.preview_url)); + + const remove = ({ key }: { key: string }) => { + revoke({ items: pending.filter((item) => item.key == key) }); setPending((prev) => prev.filter((item) => item.key != key)); + }; return { pending, add, remove, - clear: () => setPending([]), + clear: () => { + revoke({ items: pending }); + setPending([]); + }, uploading: pending.some((item) => item.status == "uploading"), media_ids: pending.flatMap((item) => item.status == "ready" && item.attachment ? [item.attachment.id] : [], diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts new file mode 100644 index 0000000..3eef9d2 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-composer-draft.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import composerDraftKey from "../(utils)/composer-draft-key"; + +type Params = { + bot_id?: number | "" | null; + workspace_id?: number | null; +}; + +/** + * The composer's text, kept in `localStorage` per bot and workspace until + * it's sent, so a reload or leaving the conversation doesn't lose it. + * Read after mount, since the page renders on the server first. + */ +export default function useComposerDraft({ bot_id, workspace_id }: Params) { + const key = composerDraftKey({ bot_id, workspace_id, part: "text" }); + const [draft, setDraft] = useState({ key, text: "" }); + + useEffect(() => { + let text = ""; + + try { + text = localStorage.getItem(key) || ""; + } catch {} + + setDraft({ key, text }); + }, [key]); + + const setText = (text: string) => { + setDraft({ key, text }); + + try { + if (text) localStorage.setItem(key, text); + else localStorage.removeItem(key); + } catch {} + }; + + return { + text: draft.key == key ? draft.text : "", + setText, + }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-chip.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-chip.tsx index 7b6f393..c91a53b 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-chip.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-chip.tsx @@ -5,6 +5,7 @@ import Span from "@moduletrace/twui/layout/Span"; import { X } from "lucide-react"; import type { PendingAttachment } from "../(hooks)/use-composer-attachments"; import AttachmentIcon from "./attachment-icon"; +import ComposerAttachmentPreview from "./composer-attachment-preview"; type Props = Omit & { onRemove: () => void; @@ -12,15 +13,23 @@ type Props = Omit & { /** * A file waiting to go with the next message: uploading, ready, or failed - * (with why, on hover). Removable until sent. + * (with why, on hover). Removable until sent. An image shows a + * small preview in place of its icon. */ export default function ComposerAttachmentChip({ name, status, attachment, error, + preview_url, onRemove, }: Props) { + const image_src = + preview_url || + (attachment?.media_type == "image" + ? attachment.thumbnail_url || attachment.url + : undefined); + return ( {status == "uploading" ? ( + ) : image_src ? ( + ) : ( )} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-preview.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-preview.tsx new file mode 100644 index 0000000..a4c73d8 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-attachment-preview.tsx @@ -0,0 +1,18 @@ +import Img from "@moduletrace/twui/layout/Img"; + +type Props = { + src: string; +}; + +/** + * A picked image's preview, the size of the icon it stands in for. + */ +export default function ComposerAttachmentPreview({ src }: Props) { + return ( + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-attachments.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-attachments.tsx index 546942e..d619368 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-attachments.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-attachments.tsx @@ -22,6 +22,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) { status={item.status} attachment={item.attachment} error={item.error} + preview_url={item.preview_url} onRemove={() => onRemove({ key: item.key })} /> ))} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx index 197aa58..1e58b65 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx @@ -8,6 +8,7 @@ import type { } from "@/src/types"; import sendBotMessage from "../(functions)/send-bot-message"; import useComposerAttachments from "../(hooks)/use-composer-attachments"; +import useComposerDraft from "../(hooks)/use-composer-draft"; import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachments from "./composer-attachments"; import ComposerSendButton from "./composer-send-button"; @@ -24,13 +25,14 @@ type Props = { /** * Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls. * Its top rule lines up with the rail's user-row rule; picked files sit - * just above it. Files can also be pasted or dropped in. + * just above it. Files can also be pasted or dropped in. Unsent text is + * kept per conversation across reloads, with its uploaded files. */ export default function Composer({ bot, workspace_id, onSent }: Props) { - const [text, setText] = useState(""); + const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id }); const [sending, setSending] = useState(false); const [error, setError] = useState(); - const files = useComposerAttachments(); + const files = useComposerAttachments({ bot_id: bot.id, workspace_id }); const can_send = Boolean(text.trim() || files.media_ids.length) && diff --git a/src/pages/admin/bots/[bot_id]/(utils)/composer-draft-key.ts b/src/pages/admin/bots/[bot_id]/(utils)/composer-draft-key.ts new file mode 100644 index 0000000..00493bf --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(utils)/composer-draft-key.ts @@ -0,0 +1,14 @@ +import { AppData } from "@/src/data/app-data"; + +type Params = { + bot_id?: number | "" | null; + workspace_id?: number | null; + part: "text" | "files"; +}; + +/** + * The `localStorage` key of one part of a conversation's unsent draft. + */ +export default function composerDraftKey({ bot_id, workspace_id, part }: Params) { + return `${AppData.ComposerDraftStorageKey}:${bot_id || 0}:${workspace_id || 0}:${part}`; +}