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:
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<PendingAttachment, "key"> & {
|
||||
onRemove: () => void;
|
||||
@@ -12,15 +13,23 @@ type Props = Omit<PendingAttachment, "key"> & {
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Row
|
||||
title={status == "failed" ? `${name}: ${error}` : name}
|
||||
@@ -28,6 +37,8 @@ export default function ComposerAttachmentChip({
|
||||
>
|
||||
{status == "uploading" ? (
|
||||
<Loading size="smaller" />
|
||||
) : image_src ? (
|
||||
<ComposerAttachmentPreview src={image_src} />
|
||||
) : (
|
||||
<AttachmentIcon media_type={attachment?.media_type} />
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<Img
|
||||
src={src}
|
||||
alt=""
|
||||
className="size-7 shrink-0 rounded-sm object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 })}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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<string>();
|
||||
const files = useComposerAttachments();
|
||||
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
|
||||
|
||||
const can_send =
|
||||
Boolean(text.trim() || files.media_ids.length) &&
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user