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 <[email protected]>
This commit is contained in:
2026-10-19 14:18:13 +01:00
co-authored by Claude Opus 5.5
parent e7a7f56ec7
commit 6c1399f88f
8 changed files with 158 additions and 9 deletions
@@ -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<PendingAttachment[]>([]);
const [loaded_key, setLoadedKey] = useState<string>();
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<PendingAttachment> & { 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] : [],
@@ -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,
};
}