Files
outline/app/scenes/Document/components/CommentThreadItem.tsx
Tom Moor fc8c20149f 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
2023-02-25 12:03:05 -08:00

280 lines
7.3 KiB
TypeScript

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 && <> &middot; </>}
{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);