Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
305e54e6a0 fix(views): persist unsent comment/reply drafts across unmount
Comment and reply inputs kept draft text in a component-local editor
ref, so the text vanished when the component unmounted — switching
issues, collapsing a comment card, or toggling reply on another
comment. Persist drafts in a workspace-scoped Zustand store keyed by
issue (main comment) or issue+comment (reply), seed the editor from
the store on mount, and clear the entry on successful submit.
2026-04-20 00:40:34 +08:00
6 changed files with 124 additions and 5 deletions

View File

@@ -0,0 +1,59 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Persists unsent comment/reply draft text so it survives unmount —
* e.g. switching issues, collapsing/expanding a comment card, or toggling
* between the main comment box and a reply box.
*
* Keys are caller-supplied strings:
* - `comment:<issueId>` for the main comment input on an issue
* - `reply:<issueId>:<commentId>` for the reply input on a specific comment
*/
interface CommentDraftStore {
drafts: Record<string, string>;
getDraft: (key: string) => string;
setDraft: (key: string, content: string) => void;
clearDraft: (key: string) => void;
}
export const useCommentDraftStore = create<CommentDraftStore>()(
persist(
(set, get) => ({
drafts: {},
getDraft: (key) => get().drafts[key] ?? "",
setDraft: (key, content) =>
set((s) => {
if (!content) {
if (!(key in s.drafts)) return s;
const { [key]: _, ...rest } = s.drafts;
return { drafts: rest };
}
if (s.drafts[key] === content) return s;
return { drafts: { ...s.drafts, [key]: content } };
}),
clearDraft: (key) =>
set((s) => {
if (!(key in s.drafts)) return s;
const { [key]: _, ...rest } = s.drafts;
return { drafts: rest };
}),
}),
{
name: "multica_comment_draft",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());
export function commentDraftKey(issueId: string): string {
return `comment:${issueId}`;
}
export function replyDraftKey(issueId: string, commentId: string): string {
return `reply:${issueId}:${commentId}`;
}

View File

@@ -8,6 +8,11 @@ export {
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export {
useCommentDraftStore,
commentDraftKey,
replyDraftKey,
} from "./comment-draft-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -36,7 +36,7 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import { useCommentCollapseStore } from "@multica/core/issues/stores";
import { useCommentCollapseStore, replyDraftKey } from "@multica/core/issues/stores";
// ---------------------------------------------------------------------------
// Types
@@ -567,6 +567,7 @@ function CommentCard({
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
draftKey={replyDraftKey(issueId, entry.id)}
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
/>
</div>

View File

@@ -7,6 +7,7 @@ import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { useCommentDraftStore, commentDraftKey } from "@multica/core/issues/stores";
interface CommentInputProps {
issueId: string;
@@ -14,8 +15,12 @@ interface CommentInputProps {
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const draftKey = commentDraftKey(issueId);
// Read the persisted draft once on mount so we can seed the editor. Later
// updates go through setDraft / clearDraft without re-rendering from the store.
const initialDraftRef = useRef<string>(useCommentDraftStore.getState().getDraft(draftKey));
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [isEmpty, setIsEmpty] = useState(!initialDraftRef.current.trim());
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
const { uploadWithToast } = useFileUpload(api);
@@ -31,6 +36,11 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
return result;
}, [uploadWithToast, issueId]);
const handleUpdate = useCallback((md: string) => {
setIsEmpty(!md.trim());
useCommentDraftStore.getState().setDraft(draftKey, md);
}, [draftKey]);
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
@@ -45,6 +55,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
useCommentDraftStore.getState().clearDraft(draftKey);
} finally {
setSubmitting(false);
}
@@ -59,7 +70,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
<ContentEditor
ref={editorRef}
placeholder="Leave a comment..."
onUpdate={(md) => setIsEmpty(!md.trim())}
defaultValue={initialDraftRef.current}
onUpdate={handleUpdate}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}

View File

@@ -236,6 +236,27 @@ vi.mock("@multica/core/issues/stores", () => ({
};
return selector ? selector(state) : state;
},
useCommentDraftStore: Object.assign(
(selector?: any) => {
const state = {
drafts: {},
getDraft: () => "",
setDraft: () => {},
clearDraft: () => {},
};
return selector ? selector(state) : state;
},
{
getState: () => ({
drafts: {},
getDraft: () => "",
setDraft: () => {},
clearDraft: () => {},
}),
},
),
commentDraftKey: (issueId: string) => `comment:${issueId}`,
replyDraftKey: (issueId: string, commentId: string) => `reply:${issueId}:${commentId}`,
}));
// Mock modals

View File

@@ -7,6 +7,7 @@ import { FileUploadButton } from "@multica/ui/components/common/file-upload-butt
import { ActorAvatar } from "../../common/actor-avatar";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { useCommentDraftStore } from "@multica/core/issues/stores";
import { cn } from "@multica/ui/lib/utils";
// ---------------------------------------------------------------------------
@@ -20,6 +21,12 @@ interface ReplyInputProps {
avatarId: string;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
size?: "sm" | "default";
/**
* Stable identifier used to persist the unsent draft across unmount
* (e.g. switching issues or collapsing the parent comment). When omitted,
* drafts are ephemeral.
*/
draftKey?: string;
}
// ---------------------------------------------------------------------------
@@ -33,10 +40,17 @@ function ReplyInput({
avatarId,
onSubmit,
size = "default",
draftKey,
}: ReplyInputProps) {
// Seed editor with any persisted draft once on mount. Omitted draftKey →
// no persistence, preserving the old ephemeral behavior for callers that
// don't want it.
const initialDraftRef = useRef<string>(
draftKey ? useCommentDraftStore.getState().getDraft(draftKey) : "",
);
const editorRef = useRef<ContentEditorRef>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [isEmpty, setIsEmpty] = useState(!initialDraftRef.current.trim());
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
@@ -64,6 +78,11 @@ function ReplyInput({
return result;
}, [uploadWithToast, issueId]);
const handleUpdate = useCallback((md: string) => {
setIsEmpty(!md.trim());
if (draftKey) useCommentDraftStore.getState().setDraft(draftKey, md);
}, [draftKey]);
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
@@ -78,6 +97,7 @@ function ReplyInput({
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
if (draftKey) useCommentDraftStore.getState().clearDraft(draftKey);
} finally {
setSubmitting(false);
}
@@ -106,7 +126,8 @@ function ReplyInput({
<ContentEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
defaultValue={initialDraftRef.current}
onUpdate={handleUpdate}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}