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:
279
app/scenes/Document/components/CommentThreadItem.tsx
Normal file
279
app/scenes/Document/components/CommentThreadItem.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { differenceInMilliseconds, formatDistanceToNow } from "date-fns";
|
||||
import { toJS } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
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 Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
|
||||
/**
|
||||
* Hook to calculate if we should display a timestamp on a comment
|
||||
*
|
||||
* @param createdAt The date the comment was created
|
||||
* @param previousCreatedAt The date of the previous comment, if any
|
||||
* @returns boolean if to show timestamp
|
||||
*/
|
||||
function useShowTime(
|
||||
createdAt: string | undefined,
|
||||
previousCreatedAt: string | undefined
|
||||
): boolean {
|
||||
if (!createdAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousTimeStamp = previousCreatedAt
|
||||
? formatDistanceToNow(Date.parse(previousCreatedAt))
|
||||
: undefined;
|
||||
const currentTimeStamp = formatDistanceToNow(Date.parse(createdAt));
|
||||
|
||||
const msSincePreviousComment = previousCreatedAt
|
||||
? differenceInMilliseconds(
|
||||
Date.parse(createdAt),
|
||||
Date.parse(previousCreatedAt)
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
!msSincePreviousComment ||
|
||||
(msSincePreviousComment > 15 * Minute &&
|
||||
previousTimeStamp !== currentTimeStamp)
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The comment to render */
|
||||
comment: Comment;
|
||||
/** The text direction of the editor */
|
||||
dir?: "rtl" | "ltr";
|
||||
/** Whether this is the first comment in the thread */
|
||||
firstOfThread?: boolean;
|
||||
/** Whether this is the last comment in the thread */
|
||||
lastOfThread?: boolean;
|
||||
/** Whether this is the first consecutive comment by this author */
|
||||
firstOfAuthor?: boolean;
|
||||
/** Whether this is the last consecutive comment by this author */
|
||||
lastOfAuthor?: boolean;
|
||||
/** The date of the previous comment in the thread */
|
||||
previousCommentCreatedAt?: string;
|
||||
};
|
||||
|
||||
function CommentThreadItem({
|
||||
comment,
|
||||
firstOfAuthor,
|
||||
firstOfThread,
|
||||
lastOfThread,
|
||||
dir,
|
||||
previousCommentCreatedAt,
|
||||
}: Props) {
|
||||
const { editor } = useDocumentContext();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [forceRender, setForceRender] = React.useState(0);
|
||||
const [data, setData] = React.useState(toJS(comment.data));
|
||||
const showAuthor = firstOfAuthor;
|
||||
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
||||
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleChange = (value: (asString: boolean) => object) => {
|
||||
setData(value(false));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
formRef.current?.dispatchEvent(
|
||||
new Event("submit", { cancelable: true, bubbles: true })
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setReadOnly();
|
||||
await comment.save({
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
setEditing();
|
||||
showToast(t("Error updating comment"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
editor?.removeComment(comment.id);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setData(toJS(comment.data));
|
||||
setReadOnly();
|
||||
setForceRender((s) => ++s);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setData(toJS(comment.data));
|
||||
setForceRender((s) => ++s);
|
||||
}, [comment.data]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
|
||||
{firstOfAuthor && (
|
||||
<AvatarSpacer>
|
||||
<Avatar model={comment.createdBy} size={24} />
|
||||
</AvatarSpacer>
|
||||
)}
|
||||
<Bubble
|
||||
$firstOfThread={firstOfThread}
|
||||
$firstOfAuthor={firstOfAuthor}
|
||||
$lastOfThread={lastOfThread}
|
||||
$dir={dir}
|
||||
column
|
||||
>
|
||||
{(showAuthor || showTime) && (
|
||||
<Meta size="xsmall" type="secondary" dir={dir}>
|
||||
{showAuthor && <em>{comment.createdBy.name}</em>}
|
||||
{showAuthor && showTime && <> · </>}
|
||||
{showTime && (
|
||||
<Time
|
||||
dateTime={comment.createdAt}
|
||||
tooltipDelay={500}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
)}
|
||||
</Meta>
|
||||
)}
|
||||
<Body ref={formRef} onSubmit={handleSubmit}>
|
||||
<StyledCommentEditor
|
||||
key={`${forceRender}`}
|
||||
readOnly={!isEditing}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
autoFocus
|
||||
/>
|
||||
{isEditing && (
|
||||
<Flex align="flex-end" gap={8}>
|
||||
<ButtonSmall type="submit" borderOnHover>
|
||||
{t("Save")}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
||||
{t("Cancel")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
)}
|
||||
</Body>
|
||||
{!isEditing && (
|
||||
<Menu
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
dir={dir}
|
||||
/>
|
||||
)}
|
||||
</Bubble>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
${(props) =>
|
||||
!props.readOnly &&
|
||||
css`
|
||||
box-shadow: 0 0 0 2px ${props.theme.accent};
|
||||
border-radius: 2px;
|
||||
padding: 2px;
|
||||
margin: 2px;
|
||||
margin-bottom: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const AvatarSpacer = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const Menu = styled(CommentMenu)<{ dir?: "rtl" | "ltr" }>`
|
||||
position: absolute;
|
||||
left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")};
|
||||
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
|
||||
top: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
background: ${(props) => props.theme.sidebarActiveBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
const Meta = styled(Text)`
|
||||
margin-bottom: 2px;
|
||||
|
||||
em {
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Bubble = styled(Flex)<{
|
||||
$firstOfThread?: boolean;
|
||||
$firstOfAuthor?: boolean;
|
||||
$lastOfThread?: boolean;
|
||||
$focused?: boolean;
|
||||
$dir?: "rtl" | "ltr";
|
||||
}>`
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
font-size: 15px;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.commentBackground};
|
||||
min-width: 2em;
|
||||
margin-bottom: 1px;
|
||||
padding: 8px 12px;
|
||||
transition: color 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
|
||||
${({ $lastOfThread }) =>
|
||||
$lastOfThread &&
|
||||
"border-bottom-left-radius: 8px; border-bottom-right-radius: 8px"};
|
||||
|
||||
${({ $firstOfThread }) =>
|
||||
$firstOfThread &&
|
||||
"border-top-left-radius: 8px; border-top-right-radius: 8px"};
|
||||
|
||||
margin-left: ${(props) =>
|
||||
props.$firstOfAuthor || props.$dir === "rtl" ? 0 : 32}px;
|
||||
margin-right: ${(props) =>
|
||||
props.$firstOfAuthor || props.$dir !== "rtl" ? 0 : 32}px;
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover ${Menu} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CommentThreadItem);
|
||||
Reference in New Issue
Block a user