feat(desktop): preserve message drafts across channel switches (#202)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-29 09:43:12 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 195fdfff2f
commit d99e1948a6
4 changed files with 268 additions and 126 deletions
@@ -0,0 +1,34 @@
import * as React from "react";
export type DraftState = {
content: string;
selectionStart: number;
selectionEnd: number;
};
export function useDrafts() {
const draftsRef = React.useRef(new Map<string, DraftState>());
const saveDraft = React.useCallback(
(channelId: string, draft: DraftState) => {
if (draft.content.trim().length === 0) {
return;
}
draftsRef.current.set(channelId, draft);
},
[],
);
const loadDraft = React.useCallback(
(channelId: string): DraftState | undefined => {
return draftsRef.current.get(channelId);
},
[],
);
const clearDraft = React.useCallback((channelId: string) => {
draftsRef.current.delete(channelId);
}, []);
return { saveDraft, loadDraft, clearDraft };
}
@@ -0,0 +1,123 @@
import * as React from "react";
import {
type BlobDescriptor,
pickAndUploadMedia,
uploadMediaBytes,
} from "@/shared/api/tauri";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
type UploadState = {
status: "idle" | "uploading" | "error";
message?: string;
};
export function useMediaUpload(
setContent: React.Dispatch<React.SetStateAction<string>>,
) {
const [uploadState, setUploadState] = React.useState<UploadState>({
status: "idle",
});
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
const pendingImetaRef = React.useRef(pendingImeta);
pendingImetaRef.current = pendingImeta;
const onUploaded = React.useCallback(
(descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
setContent((prev) => prev + markdown);
setPendingImeta((prev) => [...prev, descriptor]);
setUploadState({ status: "idle" });
},
[setContent],
);
const handlePaperclip = React.useCallback(async () => {
setUploadState({ status: "uploading" });
try {
const descriptor = await pickAndUploadMedia();
if (descriptor) {
onUploaded(descriptor);
} else {
setUploadState({ status: "idle" });
}
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
}, [onUploaded]);
const handleDrop = React.useCallback(
async (event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (!file) return;
if (!ALLOWED_TYPES.includes(file.type)) {
setUploadState({
status: "error",
message: "Only JPEG, PNG, GIF, and WebP images are supported",
});
return;
}
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
},
[onUploaded],
);
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
},
[],
);
const handlePaste = React.useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(event.clipboardData.items);
const imageItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
if (!imageItem) return;
event.preventDefault();
const file = imageItem.getAsFile();
if (!file) return;
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
},
[onUploaded],
);
const isUploading = uploadState.status === "uploading";
return {
handleDragOver,
handleDrop,
handlePaperclip,
handlePaste,
isUploading,
pendingImeta,
pendingImetaRef,
setPendingImeta,
setUploadState,
uploadState,
};
}
@@ -2,13 +2,10 @@ import * as React from "react";
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { useDrafts } from "@/features/messages/lib/useDrafts";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import {
type BlobDescriptor,
pickAndUploadMedia,
uploadMediaBytes,
} from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
@@ -72,43 +69,72 @@ export function MessageComposer({
// Keep contentRef in sync — no extra re-render, just a ref assignment.
contentRef.current = content;
const drafts = useDrafts();
const previousChannelIdRef = React.useRef<string | null>(null);
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
const notifyTyping = useTypingBroadcast(channelId);
const [uploadState, setUploadState] = React.useState<{
status: "idle" | "uploading" | "error";
message?: string;
}>({ status: "idle" });
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
const media = useMediaUpload(setContent);
// Stable refs for values read inside callbacks that should not cause
// callback identity changes when they update.
const pendingImetaRef = React.useRef(pendingImeta);
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const onSendRef = React.useRef(onSend);
const onEditSaveRef = React.useRef(onEditSave);
const editTargetRef = React.useRef(editTarget);
pendingImetaRef.current = pendingImeta;
const channelIdRef = React.useRef(channelId);
disabledRef.current = disabled;
isSendingRef.current = isSending;
onSendRef.current = onSend;
onEditSaveRef.current = onEditSave;
editTargetRef.current = editTarget;
channelIdRef.current = channelId;
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — reset all composer state on channel switch to prevent draft/upload/autocomplete leaks
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — save draft for previous channel, restore draft for new channel, reset transient state
React.useEffect(() => {
setContent("");
contentRef.current = "";
setPendingImeta([]);
setUploadState({ status: "idle" });
// Save draft for the channel we're leaving
const prevId = previousChannelIdRef.current;
if (prevId) {
const currentContent = contentRef.current;
const sel = draftSelectionRef.current;
if (currentContent.trim().length > 0) {
drafts.saveDraft(prevId, {
content: currentContent,
selectionEnd: sel.end,
selectionStart: sel.start,
});
} else {
drafts.clearDraft(prevId);
}
}
previousChannelIdRef.current = channelId;
// Restore draft for the channel we're entering
const saved = channelId ? drafts.loadDraft(channelId) : undefined;
if (saved) {
setContent(saved.content);
contentRef.current = saved.content;
draftSelectionRef.current = {
end: saved.selectionEnd,
start: saved.selectionStart,
};
pendingSelectionRef.current = saved.selectionStart;
} else {
setContent("");
contentRef.current = "";
draftSelectionRef.current = { end: 0, start: 0 };
}
// Always reset transient state
media.setPendingImeta([]);
media.setUploadState({ status: "idle" });
setIsEmojiPickerOpen(false);
setComposerScrollTop(0);
mentions.clearMentions();
channelLinks.clearChannels();
draftSelectionRef.current = { end: 0, start: 0 };
pendingSelectionRef.current = null;
lineHeightRef.current = null;
}, [channelId]);
@@ -223,97 +249,6 @@ export function MessageComposer({
mentions.updateMentionQuery(nextContent, nextCursor);
}, [mentions.updateMentionQuery]);
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
setContent((prev) => prev + markdown);
setPendingImeta((prev) => [...prev, descriptor]);
setUploadState({ status: "idle" });
}, []);
const handlePaperclip = React.useCallback(async () => {
setUploadState({ status: "uploading" });
try {
const descriptor = await pickAndUploadMedia();
if (descriptor) {
onUploaded(descriptor);
} else {
setUploadState({ status: "idle" });
}
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
}, [onUploaded]);
const handleDrop = React.useCallback(
async (event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (!file) return;
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
if (!ALLOWED_TYPES.includes(file.type)) {
setUploadState({
status: "error",
message: "Only JPEG, PNG, GIF, and WebP images are supported",
});
return;
}
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
},
[onUploaded],
);
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
},
[],
);
const handlePaste = React.useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(event.clipboardData.items);
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
const imageItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
if (!imageItem) return;
event.preventDefault();
const file = imageItem.getAsFile();
if (!file) return;
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
},
[onUploaded],
);
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLTextAreaElement>) => {
setComposerScrollTop(event.currentTarget.scrollTop);
@@ -345,7 +280,7 @@ export function MessageComposer({
return;
}
const currentPendingImeta = pendingImetaRef.current;
const currentPendingImeta = media.pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
if (
(!trimmed && !hasMedia) ||
@@ -376,18 +311,25 @@ export function MessageComposer({
setContent("");
draftSelectionRef.current = { end: 0, start: 0 };
setPendingImeta([]);
media.setPendingImeta([]);
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
const sendChannelId = channelIdRef.current;
try {
await onSendRef.current(trimmed, pubkeys, mediaTags);
if (sendChannelId) {
drafts.clearDraft(sendChannelId);
}
} catch {
setContent(savedContent);
setPendingImeta(savedImeta);
media.setPendingImeta(savedImeta);
}
}, [
drafts.clearDraft,
media.pendingImetaRef,
media.setPendingImeta,
mentions.extractMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
@@ -535,12 +477,11 @@ export function MessageComposer({
textareaRef.current?.focus();
}, [editTarget?.id]);
const isUploading = uploadState.status === "uploading";
const sendDisabled = React.useMemo(
() =>
disabled || (content.trim().length === 0 && pendingImeta.length === 0),
[disabled, content, pendingImeta.length],
disabled ||
(content.trim().length === 0 && media.pendingImeta.length === 0),
[disabled, content, media.pendingImeta.length],
);
const handleCaptureSelection = React.useCallback(() => {
@@ -548,8 +489,8 @@ export function MessageComposer({
}, [updateDraftSelection]);
const handlePaperclipClick = React.useCallback(() => {
void handlePaperclip();
}, [handlePaperclip]);
void media.handlePaperclip();
}, [media.handlePaperclip]);
return (
<footer className="border-t border-border/80 bg-background p-4">
@@ -557,9 +498,9 @@ export function MessageComposer({
<form
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
data-testid="message-composer"
onDragOver={handleDragOver}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void handleDrop(e);
void media.handleDrop(e);
}}
onSubmit={(event) => {
handleSubmit(event);
@@ -626,12 +567,12 @@ export function MessageComposer({
</div>
) : null}
{uploadState.status === "error" ? (
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {uploadState.message}
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => setUploadState({ status: "idle" })}
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
@@ -658,7 +599,7 @@ export function MessageComposer({
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={(e) => {
void handlePaste(e);
void media.handlePaste(e);
}}
onScroll={handleScroll}
onSelect={(event) => {
@@ -682,7 +623,7 @@ export function MessageComposer({
composerDisabled={disabled}
isEmojiPickerOpen={isEmojiPickerOpen}
isSending={isSending}
isUploading={isUploading}
isUploading={media.isUploading}
onCaptureSelection={handleCaptureSelection}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
+44
View File
@@ -132,6 +132,50 @@ test("messages persist across channel switches", async ({ page }) => {
await expect(page.getByTestId("message-timeline")).toContainText(message);
});
test("draft is preserved when switching channels", async ({ page }) => {
const draft = `Unsent draft ${Date.now()}`;
const input = page.getByTestId("message-input");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Type a draft but do not send it
await input.fill(draft);
await expect(input).toHaveValue(draft);
// Switch to another channel — composer should be empty
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(input).toHaveValue("");
// Switch back — the draft should still be there
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(input).toHaveValue(draft);
});
test("sending a message clears the draft", async ({ page }) => {
const message = `Sent message ${Date.now()}`;
const input = page.getByTestId("message-input");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Type and send a message
await input.fill(message);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(message);
// Switch away and back — composer should be empty, not restored from draft
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(input).toHaveValue("");
});
test("different channels have independent messages", async ({ page }) => {
const ts = Date.now();
const generalMessage = `General only ${ts}`;