mirror of
https://github.com/outline/outline.git
synced 2025-12-20 18:19:43 -06:00
462 lines
12 KiB
TypeScript
462 lines
12 KiB
TypeScript
import { differenceInMilliseconds } from "date-fns";
|
|
import { action } from "mobx";
|
|
import { observer } from "mobx-react";
|
|
import { DoneIcon } from "outline-icons";
|
|
import { darken } from "polished";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import styled, { css } from "styled-components";
|
|
import breakpoint from "styled-components-breakpoint";
|
|
import EventBoundary from "@shared/components/EventBoundary";
|
|
import { s, hover } from "@shared/styles";
|
|
import { ProsemirrorData } from "@shared/types";
|
|
import { dateToRelative } from "@shared/utils/date";
|
|
import { Minute } from "@shared/utils/time";
|
|
import Comment from "~/models/Comment";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import ButtonSmall from "~/components/ButtonSmall";
|
|
import Flex from "~/components/Flex";
|
|
import NudeButton from "~/components/NudeButton";
|
|
import ReactionList from "~/components/Reactions/ReactionList";
|
|
import ReactionPicker from "~/components/Reactions/ReactionPicker";
|
|
import Text from "~/components/Text";
|
|
import Time from "~/components/Time";
|
|
import Tooltip from "~/components/Tooltip";
|
|
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
|
import useBoolean from "~/hooks/useBoolean";
|
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
import CommentMenu from "~/menus/CommentMenu";
|
|
import CommentEditor from "./CommentEditor";
|
|
import { HighlightedText } from "./HighlightText";
|
|
import { useDocumentContext } from "~/components/DocumentContext";
|
|
|
|
/**
|
|
* 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
|
|
? dateToRelative(Date.parse(previousCreatedAt))
|
|
: undefined;
|
|
const currentTimeStamp = dateToRelative(Date.parse(createdAt));
|
|
|
|
const msSincePreviousComment = previousCreatedAt
|
|
? differenceInMilliseconds(
|
|
Date.parse(createdAt),
|
|
Date.parse(previousCreatedAt)
|
|
)
|
|
: 0;
|
|
|
|
return (
|
|
!msSincePreviousComment ||
|
|
(msSincePreviousComment > 15 * Minute.ms &&
|
|
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;
|
|
/** Whether the user can reply in the thread */
|
|
canReply: boolean;
|
|
/** Callback when the comment has been deleted */
|
|
onDelete?: (id: string) => void;
|
|
/** Callback when the comment has been updated */
|
|
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
|
|
/** Text to highlight at the top of the comment */
|
|
highlightedText?: string;
|
|
/** Whether to force the comment into edit mode */
|
|
forceEdit?: boolean;
|
|
/** Callback when edit mode starts */
|
|
onEditStart?: () => void;
|
|
/** Callback when edit mode ends */
|
|
onEditEnd?: () => void;
|
|
};
|
|
|
|
function CommentThreadItem({
|
|
comment,
|
|
firstOfAuthor,
|
|
firstOfThread,
|
|
lastOfThread,
|
|
dir,
|
|
previousCommentCreatedAt,
|
|
canReply,
|
|
onDelete,
|
|
onUpdate,
|
|
highlightedText,
|
|
forceEdit,
|
|
onEditStart,
|
|
onEditEnd,
|
|
}: Props) {
|
|
const { setFocusedCommentId } = useDocumentContext();
|
|
const { t } = useTranslation();
|
|
const user = useCurrentUser();
|
|
const [data, setData] = React.useState(comment.data);
|
|
const showAuthor = firstOfAuthor;
|
|
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
|
const showEdited =
|
|
comment.updatedAt &&
|
|
comment.updatedAt !== comment.createdAt &&
|
|
!comment.isResolved;
|
|
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
|
|
|
// Handle forced edit mode
|
|
React.useEffect(() => {
|
|
if (forceEdit && !isEditing) {
|
|
setEditing();
|
|
onEditStart?.();
|
|
}
|
|
}, [forceEdit, isEditing, setEditing, onEditStart]);
|
|
|
|
// Override setReadOnly to call onEditEnd
|
|
const handleSetReadOnly = React.useCallback(() => {
|
|
setReadOnly();
|
|
onEditEnd?.();
|
|
}, [setReadOnly, onEditEnd]);
|
|
const formRef = React.useRef<HTMLFormElement>(null);
|
|
|
|
const handleAddReaction = React.useCallback(
|
|
async (emoji: string) => {
|
|
await comment.addReaction({ emoji, user });
|
|
},
|
|
[comment, user]
|
|
);
|
|
|
|
const handleRemoveReaction = React.useCallback(
|
|
async (emoji: string) => {
|
|
await comment.removeReaction({ emoji, user });
|
|
},
|
|
[comment, user]
|
|
);
|
|
|
|
const handleUpdate = React.useCallback(
|
|
(attrs: { resolved: boolean }) => {
|
|
onUpdate?.(comment.id, attrs);
|
|
if ("resolved" in attrs) {
|
|
setFocusedCommentId(null);
|
|
}
|
|
},
|
|
[comment.id, onUpdate]
|
|
);
|
|
|
|
const handleDelete = React.useCallback(() => {
|
|
onDelete?.(comment.id);
|
|
}, [comment.id, onDelete]);
|
|
|
|
const handleChange = React.useCallback(
|
|
(value: (asString: boolean) => ProsemirrorData) => {
|
|
setData(value(false));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleSave = React.useCallback(() => {
|
|
formRef.current?.dispatchEvent(
|
|
new Event("submit", { cancelable: true, bubbles: true })
|
|
);
|
|
}, []);
|
|
|
|
const handleSubmit = action(async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
|
|
try {
|
|
handleSetReadOnly();
|
|
comment.data = data;
|
|
await comment.save();
|
|
} catch (_err) {
|
|
setEditing();
|
|
toast.error(t("Error updating comment"));
|
|
}
|
|
});
|
|
|
|
const handleCancel = () => {
|
|
setData(comment.data);
|
|
handleSetReadOnly();
|
|
};
|
|
|
|
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}
|
|
$canReply={canReply}
|
|
column
|
|
>
|
|
{(showAuthor || showTime) && (
|
|
<Meta size="xsmall" type="secondary" dir={dir}>
|
|
{showAuthor && <em>{comment.createdBy.name}</em>}
|
|
{showAuthor && showTime && <> · </>}
|
|
{showTime && (
|
|
<Time dateTime={comment.createdAt} addSuffix shorten />
|
|
)}
|
|
{showEdited && (
|
|
<>
|
|
{" "}
|
|
(<Time dateTime={comment.updatedAt}>{t("edited")}</Time>)
|
|
</>
|
|
)}
|
|
</Meta>
|
|
)}
|
|
{highlightedText && (
|
|
<HighlightedText>{highlightedText}</HighlightedText>
|
|
)}
|
|
<Body ref={formRef} onSubmit={handleSubmit}>
|
|
<StyledCommentEditor
|
|
key={String(isEditing)}
|
|
readOnly={!isEditing}
|
|
value={comment.data}
|
|
defaultValue={data}
|
|
onChange={handleChange}
|
|
onSave={handleSave}
|
|
onCancel={handleCancel}
|
|
autoFocus
|
|
/>
|
|
{isEditing && (
|
|
<Flex align="flex-end" gap={8}>
|
|
<ButtonSmall type="submit" borderOnHover>
|
|
{t("Save")}
|
|
</ButtonSmall>
|
|
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
|
{t("Cancel")}
|
|
</ButtonSmall>
|
|
</Flex>
|
|
)}
|
|
{!!comment.reactions.length && (
|
|
<ReactionListContainer gap={6} align="center">
|
|
<ReactionList
|
|
model={comment}
|
|
onAddReaction={handleAddReaction}
|
|
onRemoveReaction={handleRemoveReaction}
|
|
picker={
|
|
!comment.isResolved ? (
|
|
<Action
|
|
as={ReactionPicker}
|
|
onSelect={handleAddReaction}
|
|
size={28}
|
|
$rounded
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</ReactionListContainer>
|
|
)}
|
|
</Body>
|
|
<EventBoundary>
|
|
{!isEditing && (
|
|
<Actions gap={4} dir={dir}>
|
|
{!comment.isResolved && (
|
|
<>
|
|
{firstOfThread && (
|
|
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
|
)}
|
|
<Action
|
|
as={ReactionPicker}
|
|
onSelect={handleAddReaction}
|
|
$rounded
|
|
/>
|
|
</>
|
|
)}
|
|
<Action
|
|
as={CommentMenu}
|
|
comment={comment}
|
|
onEdit={() => {
|
|
setEditing();
|
|
onEditStart?.();
|
|
}}
|
|
onDelete={handleDelete}
|
|
onUpdate={handleUpdate}
|
|
/>
|
|
</Actions>
|
|
)}
|
|
</EventBoundary>
|
|
</Bubble>
|
|
</Flex>
|
|
);
|
|
}
|
|
|
|
const ResolveButton = ({
|
|
comment,
|
|
onUpdate,
|
|
}: {
|
|
comment: Comment;
|
|
onUpdate: (attrs: { resolved: boolean }) => void;
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<Tooltip content={t("Mark as resolved")} placement="top">
|
|
<Action
|
|
as={NudeButton}
|
|
action={resolveCommentFactory({
|
|
comment,
|
|
onResolve: () => onUpdate({ resolved: true }),
|
|
})}
|
|
$rounded
|
|
>
|
|
<DoneIcon size={22} outline />
|
|
</Action>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
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;
|
|
`}
|
|
|
|
.mention {
|
|
background: ${(props) => darken(0.05, props.theme.mentionBackground)};
|
|
}
|
|
`;
|
|
|
|
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 Action = styled.span<{ $rounded?: boolean }>`
|
|
color: ${s("textSecondary")};
|
|
${(props) =>
|
|
props.$rounded &&
|
|
css`
|
|
border-radius: 50%;
|
|
`}
|
|
|
|
svg {
|
|
fill: currentColor;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
&:
|
|
${hover},
|
|
&[aria-expanded= "true"] {
|
|
background: ${s("backgroundQuaternary")};
|
|
|
|
svg {
|
|
opacity: 0.75;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const Actions = styled(Flex)<{ 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;
|
|
background: ${s("backgroundSecondary")};
|
|
padding-left: 4px;
|
|
|
|
&:has(${Action}[aria-expanded="true"]) {
|
|
opacity: 1;
|
|
}
|
|
`;
|
|
|
|
const ReactionListContainer = styled(Flex)`
|
|
margin-top: 6px;
|
|
`;
|
|
|
|
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;
|
|
$canReply?: boolean;
|
|
$focused?: boolean;
|
|
$dir?: "rtl" | "ltr";
|
|
}>`
|
|
position: relative;
|
|
flex-grow: 1;
|
|
font-size: 16px;
|
|
color: ${s("text")};
|
|
background: ${s("backgroundSecondary")};
|
|
min-width: 2em;
|
|
margin-bottom: 1px;
|
|
padding: 8px 12px;
|
|
transition:
|
|
color 100ms ease-out,
|
|
background 100ms ease-out;
|
|
|
|
${({ $lastOfThread, $canReply }) =>
|
|
$lastOfThread &&
|
|
!$canReply &&
|
|
"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} ${Actions} {
|
|
opacity: 1;
|
|
}
|
|
|
|
${breakpoint("tablet")`
|
|
font-size: 15px;
|
|
`}
|
|
`;
|
|
|
|
export default observer(CommentThreadItem);
|