feat: Comments (#4911)
* Comment model * Framework, model, policy, presenter, api endpoint etc * Iteration, first pass of UI * fixes, refactors * Comment commands * comment socket support * typing indicators * comment component, styling * wip * right sidebar resize * fix: CMD+Enter submit * Add usePersistedState fix: Main page scrolling on comment highlight * drafts * Typing indicator * refactor * policies * Click thread to highlight Improve comment timestamps * padding * Comment menu v1 * Change comments to use editor * Basic comment editing * fix: Hide commenting button when disabled at team level * Enable opening sidebar without mark * Move selected comment to location state * Add comment delete confirmation * Add comment count to document meta * fix: Comment sidebar togglable Add copy link to comment * stash * Restore History changes * Refactor right sidebar to allow for comment animation * Update to new router best practices * stash * Various improvements * stash * Handle click outside * Fix incorrect placeholder in input fix: Input box appearing on other sessions erroneously * stash * fix: Don't leave orphaned child comments * styling * stash * Enable comment toggling again * Edit styling, merge conflicts * fix: Cannot navigate from insights to comments * Remove draft comment mark on click outside * Fix: Empty comment sidebar, tsc * Remove public toggle * fix: All comments are recessed fix: Comments should not be printed * fix: Associated mark should be removed on comment delete * Revert unused changes * Empty state, basic RTL support * Create dont toggle comment mark * Make it feel more snappy * Highlight active comment in text * fix animation * RTL support * Add reply CTA * Translations
This commit is contained in:
229
app/scenes/Document/components/CommentForm.tsx
Normal file
229
app/scenes/Document/components/CommentForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { m } from "framer-motion";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { 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 type { Editor as SharedEditor } from "~/editor";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { Bubble } from "./CommentThreadItem";
|
||||
|
||||
type Props = {
|
||||
/** 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;
|
||||
/** 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;
|
||||
/** Callback when the editor is clicked outside of */
|
||||
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
|
||||
};
|
||||
|
||||
function CommentForm({
|
||||
documentId,
|
||||
thread,
|
||||
onTyping,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onClickOutside,
|
||||
autoFocus,
|
||||
standalone,
|
||||
placeholder,
|
||||
animatePresence,
|
||||
dir,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { editor } = useDocumentContext();
|
||||
const [data, setData] = usePersistedState<Record<string, any> | undefined>(
|
||||
`draft-${documentId}-${thread.id}`,
|
||||
undefined
|
||||
);
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const editorRef = React.useRef<SharedEditor>(null);
|
||||
const [forceRender, setForceRender] = React.useState(0);
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const { comments } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const isEmpty = editorRef.current?.isEmpty() ?? true;
|
||||
|
||||
useOnClickOutside(formRef, () => {
|
||||
if (isEmpty && thread.isNew) {
|
||||
if (thread.id) {
|
||||
editor?.removeComment(thread.id);
|
||||
}
|
||||
thread.delete();
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateComment = action(async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setData(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
thread
|
||||
.save({
|
||||
documentId,
|
||||
data,
|
||||
})
|
||||
.catch(() => {
|
||||
thread.isNew = true;
|
||||
showToast(t("Error creating comment"), { type: "error" });
|
||||
});
|
||||
|
||||
// optimistically update the comment model
|
||||
thread.isNew = false;
|
||||
thread.createdBy = user;
|
||||
});
|
||||
|
||||
const handleCreateReply = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
try {
|
||||
await comments.save({
|
||||
parentCommentId: thread?.id,
|
||||
documentId,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(t("Error creating comment"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
value: (asString: boolean, trim: boolean) => Record<string, any>
|
||||
) => {
|
||||
setData(value(false, true));
|
||||
onTyping?.();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
formRef.current?.dispatchEvent(
|
||||
new Event("submit", { cancelable: true, bubbles: true })
|
||||
);
|
||||
};
|
||||
|
||||
const handleClickPadding = () => {
|
||||
if (editorRef.current?.isBlurred) {
|
||||
editorRef.current?.focusAtStart();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setData(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
};
|
||||
|
||||
// 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,
|
||||
translateY: 100,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
translateY: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
bounce: 0.1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
translateY: 100,
|
||||
scale: 0.98,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<m.form
|
||||
ref={formRef}
|
||||
onSubmit={thread?.isNew ? handleCreateComment : handleCreateReply}
|
||||
{...presence}
|
||||
{...rest}
|
||||
>
|
||||
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
|
||||
<Avatar model={user} size={24} style={{ marginTop: 8 }} />
|
||||
<Bubble
|
||||
gap={10}
|
||||
onClick={handleClickPadding}
|
||||
$lastOfThread
|
||||
$firstOfAuthor
|
||||
$firstOfThread={standalone}
|
||||
column
|
||||
>
|
||||
<CommentEditor
|
||||
key={`${forceRender}`}
|
||||
ref={editorRef}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
maxLength={CommentValidation.maxLength}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
// isNew is only the case for comments that exist in draft state,
|
||||
// they are marks in the document, but not yet saved to the db.
|
||||
(thread.isNew ? `${t("Add a comment")}…` : `${t("Add a reply")}…`)
|
||||
}
|
||||
/>
|
||||
|
||||
{!isEmpty && (
|
||||
<Flex justify={dir === "rtl" ? "flex-end" : "flex-start"} gap={8}>
|
||||
<ButtonSmall type="submit" borderOnHover>
|
||||
{thread.isNew ? t("Post") : t("Reply")}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
||||
{t("Cancel")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
)}
|
||||
</Bubble>
|
||||
</Flex>
|
||||
</m.form>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentForm);
|
||||
Reference in New Issue
Block a user