import { m } from "framer-motion"; import { action } from "mobx"; import { observer } from "mobx-react"; import { ImageIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { VisuallyHidden } from "reakit"; import { toast } from "sonner"; import { useTheme } from "styled-components"; import { v4 as uuidv4 } from "uuid"; import { ProsemirrorData } from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; import { AttachmentValidation, CommentValidation } from "@shared/validations"; import Comment from "~/models/Comment"; import Avatar from "~/components/Avatar"; import ButtonSmall from "~/components/ButtonSmall"; import { useDocumentContext } from "~/components/DocumentContext"; import Flex from "~/components/Flex"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import type { Editor as SharedEditor } from "~/editor"; import useCurrentUser from "~/hooks/useCurrentUser"; import useOnClickOutside from "~/hooks/useOnClickOutside"; import useStores from "~/hooks/useStores"; import CommentEditor from "./CommentEditor"; import { Bubble } from "./CommentThreadItem"; import { HighlightedText } from "./HighlightText"; type Props = { /** Callback when the draft should be saved. */ onSaveDraft: (data: ProsemirrorData | undefined) => void; /** A draft comment for this thread. */ draft?: ProsemirrorData; /** The document that the comment will be associated with */ documentId: string; /** The comment thread that the comment will be associated with */ thread?: Comment; /** Placeholder text to display in the editor */ placeholder?: string; /** Whether to focus the editor on mount */ autoFocus?: boolean; /** Whether to render the comment form as standalone, rather than as a reply */ standalone?: boolean; /** Whether to animate the comment form in and out */ animatePresence?: boolean; /** Text to highlight at the top of the comment */ highlightedText?: string; /** The text direction of the editor */ dir?: "rtl" | "ltr"; /** Callback when the user is typing in the editor */ onTyping?: () => void; /** Callback when the editor is focused */ onFocus?: () => void; /** Callback when the editor is blurred */ onBlur?: () => void; }; function CommentForm({ documentId, thread, draft, onSaveDraft, onTyping, onFocus, onBlur, autoFocus, standalone, placeholder, animatePresence, highlightedText, dir, ...rest }: Props) { const { editor } = useDocumentContext(); const formRef = React.useRef(null); const editorRef = React.useRef(null); const [forceRender, setForceRender] = React.useState(0); const [inputFocused, setInputFocused] = React.useState(autoFocus); const file = React.useRef(null); const theme = useTheme(); const { t } = useTranslation(); const { comments } = useStores(); const user = useCurrentUser(); const reset = React.useCallback(async () => { const isEmpty = editorRef.current?.isEmpty() ?? true; if (isEmpty && thread?.isNew) { if (thread.id) { editor?.removeComment(thread.id); } await thread.delete(); } }, [editor, thread]); useOnClickOutside(formRef, reset); const handleCreateComment = action(async (event: React.FormEvent) => { event.preventDefault(); onSaveDraft(undefined); setForceRender((s) => ++s); setInputFocused(false); const comment = thread ?? new Comment( { createdAt: new Date().toISOString(), documentId, data: draft, }, comments ); comment .save({ documentId, data: draft, }) .catch(() => { comment.isNew = true; toast.error(t("Error creating comment")); }); // optimistically update the comment model comment.isNew = false; comment.createdById = user.id; comment.createdBy = user; }); const handleCreateReply = action(async (event: React.FormEvent) => { event.preventDefault(); if (!draft) { return; } onSaveDraft(undefined); setForceRender((s) => ++s); const comment = new Comment( { createdAt: new Date().toISOString(), parentCommentId: thread?.id, documentId, data: draft, }, comments ); comment.id = uuidv4(); comments.add(comment); comment.save().catch(() => { comments.remove(comment.id); comment.isNew = true; toast.error(t("Error creating comment")); }); // optimistically update the comment model comment.isNew = false; comment.createdById = user.id; comment.createdBy = user; // re-focus the comment editor setTimeout(() => { editorRef.current?.focusAtStart(); }, 0); }); const handleChange = ( value: (asString: boolean, trim: boolean) => ProsemirrorData ) => { const text = value(true, true); onSaveDraft(text ? value(false, true) : undefined); onTyping?.(); }; const handleSave = () => { formRef.current?.dispatchEvent( new Event("submit", { cancelable: true, bubbles: true }) ); }; const handleClickPadding = () => { if (editorRef.current?.isBlurred) { editorRef.current?.focusAtStart(); } }; const handleCancel = async () => { onSaveDraft(undefined); setForceRender((s) => ++s); setInputFocused(false); await reset(); }; const handleFocus = () => { onFocus?.(); setInputFocused(true); }; const handleBlur = () => { onBlur?.(); }; const handleFilePicked = (event: React.ChangeEvent) => { event.stopPropagation(); event.preventDefault(); const files = getEventFiles(event); if (!files.length) { return; } return editorRef.current?.insertFiles(event, files); }; const handleImageUpload = (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); file.current?.click(); }; // Focus the editor when it's a new comment just mounted, after a delay as the // editor is mounted within a fade transition. React.useEffect(() => { setTimeout(() => { if (autoFocus) { editorRef.current?.focusAtStart(); } }, 0); }, [autoFocus]); const presence = animatePresence ? { initial: { opacity: 0, marginBottom: -100, }, animate: { opacity: 1, marginBottom: 0, transition: { type: "spring", bounce: 0.1, }, }, exit: { opacity: 0, marginBottom: -100, scale: 0.98, }, } : {}; return ( {highlightedText && ( {highlightedText} )} {(inputFocused || draft) && ( {thread && !thread.isNew ? t("Reply") : t("Post")} {t("Cancel")} )} ); } export default observer(CommentForm);