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:
13
app/scenes/Document/components/CommentEditor.tsx
Normal file
13
app/scenes/Document/components/CommentEditor.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
import extensions from "@shared/editor/packages/basic";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import type { Editor as SharedEditor } from "~/editor";
|
||||
|
||||
const CommentEditor = (
|
||||
props: EditorProps,
|
||||
ref: React.RefObject<SharedEditor>
|
||||
) => {
|
||||
return <Editor extensions={extensions} {...props} ref={ref} />;
|
||||
};
|
||||
|
||||
export default React.forwardRef(CommentEditor);
|
||||
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);
|
||||
232
app/scenes/Document/components/CommentThread.tsx
Normal file
232
app/scenes/Document/components/CommentThread.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { throttle } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Typing from "~/components/Typing";
|
||||
import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThreadItem from "./CommentThreadItem";
|
||||
|
||||
type Props = {
|
||||
/** The document that this comment thread belongs to */
|
||||
document: Document;
|
||||
/** The root comment to render */
|
||||
comment: Comment;
|
||||
/** Whether the thread is focused */
|
||||
focused: boolean;
|
||||
/** Whether the thread is displayed in a recessed/backgrounded state */
|
||||
recessed: boolean;
|
||||
};
|
||||
|
||||
function useTypingIndicator({
|
||||
document,
|
||||
comment,
|
||||
}: Omit<Props, "focused" | "recessed">): [undefined, () => void] {
|
||||
const socket = React.useContext(WebsocketContext);
|
||||
|
||||
const setIsTyping = React.useMemo(
|
||||
() =>
|
||||
throttle(() => {
|
||||
socket?.emit("typing", {
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
}, 500),
|
||||
[socket, document.id, comment.id]
|
||||
);
|
||||
|
||||
return [undefined, setIsTyping];
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
comment: thread,
|
||||
document,
|
||||
recessed,
|
||||
focused,
|
||||
}: Props) {
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [, setIsTyping] = useTypingIndicator({
|
||||
document,
|
||||
comment: thread,
|
||||
});
|
||||
|
||||
const commentsInThread = comments.inThread(thread.id);
|
||||
|
||||
useOnClickOutside(topRef, (event) => {
|
||||
if (
|
||||
focused &&
|
||||
!(event.target as HTMLElement).classList.contains("comment")
|
||||
) {
|
||||
history.replace({
|
||||
pathname: window.location.pathname,
|
||||
state: { commentId: undefined },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
}
|
||||
}, [focused, autoFocus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused && topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
boundary: (parent) => {
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
return parent.id !== "comments";
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const commentMarkElement = window.document?.getElementById(
|
||||
`comment-${thread.id}`
|
||||
);
|
||||
commentMarkElement?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, [focused, thread.id]);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
ref={topRef}
|
||||
$focused={focused}
|
||||
$recessed={recessed}
|
||||
$dir={document.dir}
|
||||
onClick={handleClickThread}
|
||||
>
|
||||
{commentsInThread.map((comment, index) => {
|
||||
const firstOfAuthor =
|
||||
index === 0 ||
|
||||
comment.createdById !== commentsInThread[index - 1].createdById;
|
||||
const lastOfAuthor =
|
||||
index === commentsInThread.length - 1 ||
|
||||
comment.createdById !== commentsInThread[index + 1].createdById;
|
||||
|
||||
return (
|
||||
<CommentThreadItem
|
||||
comment={comment}
|
||||
key={comment.id}
|
||||
firstOfThread={index === 0}
|
||||
lastOfThread={index === commentsInThread.length - 1 && !focused}
|
||||
firstOfAuthor={firstOfAuthor}
|
||||
lastOfAuthor={lastOfAuthor}
|
||||
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
|
||||
dir={document.dir}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{thread.currentlyTypingUsers
|
||||
.filter((typing) => typing.id !== user.id)
|
||||
.map((typing) => (
|
||||
<Flex gap={8} key={typing.id}>
|
||||
<Avatar model={typing} size={24} />
|
||||
<Typing />
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer
|
||||
hideOverflow={false}
|
||||
config={{
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{focused && (
|
||||
<Fade timing={100}>
|
||||
<CommentForm
|
||||
documentId={document.id}
|
||||
thread={thread}
|
||||
onTyping={setIsTyping}
|
||||
standalone={commentsInThread.length === 0}
|
||||
dir={document.dir}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && (
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
);
|
||||
}
|
||||
|
||||
const Reply = styled.button`
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
-webkit-appearance: none;
|
||||
cursor: var(--pointer);
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-out;
|
||||
position: absolute;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
bottom: -30px;
|
||||
left: 32px;
|
||||
`;
|
||||
|
||||
const Thread = styled.div<{
|
||||
$focused: boolean;
|
||||
$recessed: boolean;
|
||||
$dir?: "rtl" | "ltr";
|
||||
}>`
|
||||
margin: 12px 12px 32px;
|
||||
margin-right: ${(props) => (props.$dir !== "rtl" ? "18px" : "12px")};
|
||||
margin-left: ${(props) => (props.$dir === "rtl" ? "18px" : "12px")};
|
||||
position: relative;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
&:hover {
|
||||
${Reply} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$recessed &&
|
||||
css`
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(CommentThread);
|
||||
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);
|
||||
94
app/scenes/Document/components/Comments.tsx
Normal file
94
app/scenes/Document/components/Comments.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Comment from "~/models/Comment";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThread from "./CommentThread";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
const [newComment] = React.useState(new Comment({}, comments));
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const threads = comments
|
||||
.threadsInDocument(document.id)
|
||||
.filter((thread) => !thread.isNew || thread.createdById === user.id);
|
||||
const hasComments = threads.length > 0;
|
||||
|
||||
return (
|
||||
<Sidebar title={t("Comments")} onClose={ui.collapseComments}>
|
||||
<Wrapper $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<Empty>{t("No comments yet")}</Empty>
|
||||
</NoComments>
|
||||
)}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusedComment && (
|
||||
<NewCommentForm
|
||||
key="new-comment-form"
|
||||
documentId={document.id}
|
||||
thread={newComment}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
dir={document.dir}
|
||||
animatePresence
|
||||
standalone
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const NoComments = styled(Flex)`
|
||||
padding-bottom: 65px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<{ $hasComments: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$hasComments ? "50vh" : "0")};
|
||||
height: ${(props) => (props.$hasComments ? "auto" : "100%")};
|
||||
`;
|
||||
|
||||
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
|
||||
background: ${(props) => props.theme.background};
|
||||
position: absolute;
|
||||
padding: 12px;
|
||||
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
|
||||
padding-left: ${(props) => (props.dir === "rtl" ? "18px" : "12px")};
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
export default observer(Comments);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode, TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
@@ -45,6 +45,7 @@ function DataLoader({ match, children }: Props) {
|
||||
ui,
|
||||
views,
|
||||
shares,
|
||||
comments,
|
||||
documents,
|
||||
auth,
|
||||
revisions,
|
||||
@@ -158,6 +159,12 @@ function DataLoader({ match, children }: Props) {
|
||||
// Prevents unauthorized request to load share information for the document
|
||||
// when viewing a public share link
|
||||
if (can.read) {
|
||||
if (team?.getPreference(TeamPreference.Commenting)) {
|
||||
comments.fetchDocumentComments(document.id, {
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
|
||||
shares.fetch(document.id).catch((err) => {
|
||||
if (!(err instanceof NotFoundError)) {
|
||||
throw err;
|
||||
@@ -165,7 +172,7 @@ function DataLoader({ match, children }: Props) {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [can.read, can.update, document, isEditRoute, shares, ui]);
|
||||
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from "react-router";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import getTasks from "@shared/utils/getTasks";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -638,19 +638,28 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
</Container>
|
||||
{!isShare && (
|
||||
<Footer>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</Footer>
|
||||
)}
|
||||
</Background>
|
||||
{!isShare && (
|
||||
<>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Footer = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Background = styled(Container)`
|
||||
position: relative;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
`;
|
||||
|
||||
96
app/scenes/Document/components/DocumentMeta.tsx
Normal file
96
app/scenes/Document/components/DocumentMeta.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import { CommentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
/* The document to display meta data for */
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
|
||||
const { auth, views, comments, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { team } = auth;
|
||||
const match = useRouteMatch();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
||||
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
|
||||
const insightsUrl = documentInsightsUrl(document);
|
||||
const commentsCount = comments.inDocument(document.id).length;
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to} replace {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
<Wrapper>
|
||||
•
|
||||
<Link
|
||||
to={match.url === insightsUrl ? documentUrl(document) : insightsUrl}
|
||||
>
|
||||
{t("Viewed by")}{" "}
|
||||
{onlyYou
|
||||
? t("only you")
|
||||
: `${totalViewers} ${
|
||||
totalViewers === 1 ? t("person") : t("people")
|
||||
}`}
|
||||
</Link>
|
||||
</Wrapper>
|
||||
) : null}
|
||||
{team?.getPreference(TeamPreference.Commenting) && (
|
||||
<>
|
||||
•
|
||||
<CommentLink to={documentUrl(document)} onClick={ui.toggleComments}>
|
||||
<CommentIcon color="currentColor" size={18} />
|
||||
{commentsCount
|
||||
? t("{{ count }} comment", { count: commentsCount })
|
||||
: t("Comment")}
|
||||
</CommentLink>
|
||||
</>
|
||||
)}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const CommentLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(TitleDocumentMeta);
|
||||
@@ -2,13 +2,15 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import fullPackage from "@shared/editor/packages/full";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import fullWithCommentsPackage from "@shared/editor/packages/fullWithComments";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { RefHandle } from "~/components/ContentEditable";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
documentUrl,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import { useDocumentContext } from "../../../components/DocumentContext";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
|
||||
type Props = Omit<EditorProps, "extensions"> & {
|
||||
@@ -34,12 +37,15 @@ type Props = Omit<EditorProps, "extensions"> & {
|
||||
|
||||
/**
|
||||
* The main document editor includes an editable title with metadata below it,
|
||||
* and support for hover previews of internal links.
|
||||
* and support for commenting.
|
||||
*/
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const titleRef = React.useRef<RefHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const { ui, comments, auth } = useStores();
|
||||
const { user, team } = auth;
|
||||
const history = useHistory();
|
||||
const {
|
||||
document,
|
||||
onChangeTitle,
|
||||
@@ -77,9 +83,64 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[focusAtStart, ref]
|
||||
);
|
||||
|
||||
const handleClickComment = React.useCallback(
|
||||
(commentId?: string) => {
|
||||
if (commentId) {
|
||||
ui.expandComments();
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
});
|
||||
} else {
|
||||
history.replace({
|
||||
pathname: window.location.pathname,
|
||||
});
|
||||
}
|
||||
},
|
||||
[ui, history]
|
||||
);
|
||||
|
||||
// Create a Comment model in local store when a comment mark is created, this
|
||||
// acts as a local draft before submission.
|
||||
const handleDraftComment = React.useCallback(
|
||||
(commentId: string, createdById: string) => {
|
||||
if (comments.get(commentId) || createdById !== user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = new Comment(
|
||||
{
|
||||
documentId: props.id,
|
||||
createdAt: new Date(),
|
||||
createdById,
|
||||
},
|
||||
comments
|
||||
);
|
||||
comment.id = commentId;
|
||||
comments.add(comment);
|
||||
|
||||
ui.expandComments();
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
});
|
||||
},
|
||||
[comments, user?.id, props.id, ui, history]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
const handleRemoveComment = React.useCallback(
|
||||
async (commentId: string) => {
|
||||
const comment = comments.get(commentId);
|
||||
if (comment?.isNew) {
|
||||
await comment?.delete();
|
||||
}
|
||||
},
|
||||
[comments]
|
||||
);
|
||||
|
||||
const { setEditor } = useDocumentContext();
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
return (
|
||||
@@ -95,7 +156,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
placeholder={t("Untitled")}
|
||||
/>
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
<DocumentMeta
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={
|
||||
@@ -115,7 +176,19 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
scrollTo={decodeURIComponent(window.location.hash)}
|
||||
readOnly={readOnly}
|
||||
shareId={shareId}
|
||||
extensions={fullPackage}
|
||||
userId={user?.id}
|
||||
onClickCommentMark={handleClickComment}
|
||||
onCreateCommentMark={
|
||||
team?.getPreference(TeamPreference.Commenting)
|
||||
? handleDraftComment
|
||||
: undefined
|
||||
}
|
||||
onDeleteCommentMark={
|
||||
team?.getPreference(TeamPreference.Commenting)
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
extensions={fullWithCommentsPackage}
|
||||
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,6 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 24px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
@@ -20,18 +20,13 @@ type Props = Omit<EditorProps, "extensions"> & {
|
||||
* Displays revision HTML pre-rendered on the server.
|
||||
*/
|
||||
function RevisionViewer(props: Props) {
|
||||
const { document, isDraft, shareId, children, revision } = props;
|
||||
const { document, shareId, children, revision } = props;
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<h1 dir={revision.dir}>{revision.title}</h1>
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentUrl(document)}
|
||||
rtl={revision.rtl}
|
||||
/>
|
||||
<DocumentMeta document={document} to={documentUrl(document)} />
|
||||
)}
|
||||
<EditorContainer
|
||||
dangerouslySetInnerHTML={{ __html: revision.html }}
|
||||
|
||||
@@ -31,7 +31,9 @@ function SidebarLayout({ title, onClose, children }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
<Scrollable topShadow>{children}</Scrollable>
|
||||
<Scrollable hiddenScrollbars topShadow>
|
||||
{children}
|
||||
</Scrollable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
type Props = {
|
||||
documentId: string;
|
||||
isEditing: boolean;
|
||||
presence: boolean;
|
||||
};
|
||||
|
||||
export default class SocketPresence extends React.Component<Props> {
|
||||
@@ -12,14 +13,16 @@ export default class SocketPresence extends React.Component<Props> {
|
||||
|
||||
previousContext: typeof WebsocketContext;
|
||||
|
||||
editingInterval: ReturnType<typeof setInterval>;
|
||||
editingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
componentDidMount() {
|
||||
this.editingInterval = setInterval(() => {
|
||||
if (this.props.isEditing) {
|
||||
this.emitPresence();
|
||||
}
|
||||
}, USER_PRESENCE_INTERVAL);
|
||||
this.editingInterval = this.props.presence
|
||||
? setInterval(() => {
|
||||
if (this.props.isEditing) {
|
||||
this.emitPresence();
|
||||
}
|
||||
}, USER_PRESENCE_INTERVAL)
|
||||
: undefined;
|
||||
this.setupOnce();
|
||||
}
|
||||
|
||||
@@ -39,7 +42,9 @@ export default class SocketPresence extends React.Component<Props> {
|
||||
this.context.off("authenticated", this.emitJoin);
|
||||
}
|
||||
|
||||
clearInterval(this.editingInterval);
|
||||
if (this.editingInterval) {
|
||||
clearInterval(this.editingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
setupOnce = () => {
|
||||
|
||||
Reference in New Issue
Block a user