"use client"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@multica/ui/components/ui/dialog"; import { Button } from "@multica/ui/components/ui/button"; import { FileUploadButton } from "@multica/ui/components/common/file-upload-button"; import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay, useUploadGate, useEditorUpload, } from "../editor"; import { useCreateFeedback, useFeedbackDraftStore, FEEDBACK_KINDS, type FeedbackKind, } from "@multica/core/feedback"; import { useCurrentWorkspace } from "@multica/core/paths"; import { useT } from "../i18n"; import { useShortcut } from "@multica/core/shortcuts"; import { ShortcutKeycaps } from "../common/shortcut-keycaps"; const MAX_MESSAGE_LEN = 10000; const FEEDBACK_KIND_SET = new Set(FEEDBACK_KINDS); function composeFeedbackInitialMessage(draftMessage: string, incomingInitialMessage: string) { const draft = draftMessage.trim(); const incoming = incomingInitialMessage.trim(); if (!incoming) return draftMessage; if (!draft) return incomingInitialMessage; if (draft.includes(incoming)) return draftMessage; return `${draftMessage} --- ${incomingInitialMessage}`; } export function FeedbackModal({ onClose, data, initialMessage, }: { onClose: () => void; data?: Record | null; initialMessage?: string; }) { const sendShortcut = useShortcut("send"); const { t } = useT("modals"); const { t: tEditor } = useT("editor"); const workspace = useCurrentWorkspace(); const draft = useFeedbackDraftStore((s) => s.draft); const setDraft = useFeedbackDraftStore((s) => s.setDraft); const clearDraft = useFeedbackDraftStore((s) => s.clearDraft); const editorRef = useRef(null); const incomingInitialMessage = initialMessage ?? (typeof data?.initialMessage === "string" ? data.initialMessage : ""); const kind = typeof data?.kind === "string" && FEEDBACK_KIND_SET.has(data.kind as FeedbackKind) ? (data.kind as FeedbackKind) : undefined; const seededMessage = composeFeedbackInitialMessage(draft.message, incomingInitialMessage); const [message, setMessage] = useState(seededMessage); const { isDragOver, dropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)), }); const { uploadWithToast } = useEditorUpload(); // The handler already refused to submit mid-upload, but the button stayed // clickable — so the only feedback was a toast fired after the click. const uploadGate = useUploadGate(editorRef); const mutation = useCreateFeedback(); const canSubmit = message.trim().length > 0 && message.length <= MAX_MESSAGE_LEN && !mutation.isPending && !uploadGate.uploading; const handleSubmit = async () => { // The button can use debounced `message` state, but the keyboard shortcut // must not: Command+Enter can arrive before ContentEditor's 150ms onUpdate // fires. The editor ref below is the submit-time source of truth. if (mutation.isPending) return; // Keep the toast on this path: the shortcut can fire while the button is // disabled and off-screen, so a silent no-op would read as a dead ⌘+Enter. if (uploadGate.isBlocked()) { toast.info(t(($) => $.feedback.toast_uploading)); return; } // Read from the editor ref at submit time — `message` state lags 150ms // behind keystrokes due to `debounceMs`, so ⌘+Enter fired immediately // after typing would otherwise submit stale content. const latest = editorRef.current?.getMarkdown()?.trim() ?? ""; if (!latest) return; if (latest.length > MAX_MESSAGE_LEN) { toast.error(t(($) => $.feedback.toast_too_long)); return; } try { await mutation.mutateAsync({ message: latest, url: typeof window !== "undefined" ? window.location.href : undefined, workspace_id: workspace?.id, kind, }); clearDraft(); toast.success(t(($) => $.feedback.toast_sent)); onClose(); } catch (err) { const msg = err instanceof Error && err.message ? err.message : t(($) => $.feedback.toast_failed); toast.error(msg); } }; return ( !v && onClose()}> {t(($) => $.feedback.title)}

{t(($) => $.feedback.github_hint_prefix)} {t(($) => $.feedback.github_hint_link)}

$.feedback.placeholder)} onUpdate={(md) => { setMessage(md); setDraft({ message: md }); }} onUploadFile={uploadWithToast} onUploadingChange={uploadGate.onUploadingChange} onSubmit={handleSubmit} debounceMs={150} showBubbleMenu={false} className="px-3 py-2" /> {isDragOver && }
editorRef.current?.uploadFile(file)} />
); }