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:
Tom Moor
2023-02-25 15:03:05 -05:00
committed by GitHub
parent 59e25a0ef0
commit fc8c20149f
89 changed files with 2909 additions and 315 deletions

View File

@@ -2,6 +2,7 @@ import { AnimatePresence } from "framer-motion";
import { observer, useLocalStore } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import DocumentContext from "~/components/DocumentContext";
import type { DocumentContextValue } from "~/components/DocumentContext";
@@ -16,14 +17,17 @@ import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import {
searchPath,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
const DocumentComments = React.lazy(
() => import("~/scenes/Document/components/Comments")
);
const DocumentHistory = React.lazy(
() => import("~/scenes/Document/components/History")
);
@@ -84,15 +88,21 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
const showInsights = !!matchPath(location.pathname, {
path: matchDocumentInsights,
});
const showComments =
!showInsights &&
!showHistory &&
!ui.commentsCollapsed &&
team?.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence key={ui.activeDocumentId}>
{(showHistory || showInsights) && (
<AnimatePresence>
{(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>

View File

@@ -19,15 +19,16 @@ type Props = {
showBorder?: boolean;
onClick?: React.MouseEventHandler<HTMLImageElement>;
className?: string;
style?: React.CSSProperties;
};
function Avatar(props: Props) {
const { icon, showBorder, model, ...rest } = props;
const { icon, showBorder, model, style, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative>
<Relative style={style}>
{src && !error ? (
<CircleImg
onError={handleError}
@@ -53,6 +54,7 @@ Avatar.defaultProps = {
const Relative = styled.div`
position: relative;
user-select: none;
flex-shrink: 0;
`;

View File

@@ -0,0 +1,15 @@
import styled from "styled-components";
import Button, { Inner } from "./Button";
const ButtonSmall = styled(Button)`
font-size: 13px;
height: 26px;
${Inner} {
padding: 0 6px;
line-height: 26px;
min-height: 26px;
}
`;
export default ButtonSmall;

View File

@@ -0,0 +1,53 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
comment: Comment;
onSubmit?: () => void;
};
function CommentDeleteDialog({ comment, onSubmit }: Props) {
const { comments } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const hasChildComments = comments.inThread(comment.id).length > 1;
const handleSubmit = async () => {
try {
await comment.delete();
onSubmit?.();
} catch (err) {
showToast(err.message, { type: "error" });
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Text type="secondary">
{hasChildComments ? (
<Trans>
Are you sure you want to permanently delete this entire comment
thread?
</Trans>
) : (
<Trans>
Are you sure you want to permanently delete this comment?
</Trans>
)}
</Text>
</ConfirmationDialog>
);
}
export default observer(CommentDeleteDialog);

View File

@@ -39,8 +39,8 @@ const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
transform: translateX(-32px);
${breakpoint("tablet")`
display: block;

View File

@@ -1,73 +0,0 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Document from "~/models/Document";
import DocumentMeta from "~/components/DocumentMeta";
import useStores from "~/hooks/useStores";
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
import Fade from "./Fade";
type Props = {
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
rtl?: boolean;
};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
const { t } = useTranslation();
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 insightsUrl = documentInsightsUrl(document);
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
return (
<Meta document={document} to={to} replace {...rest}>
{totalViewers && !isDraft ? (
<Wrapper>
&nbsp;&nbsp;
<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}
</Meta>
);
}
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;
z-index: 1;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
@media print {
display: none;
}
`;
export default observer(DocumentMetaWithViews);

View File

@@ -1,14 +1,15 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { deburr, difference, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { useHistory } from "react-router-dom";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { Heading } from "@shared/editor/lib/getHeadings";
import { AttachmentPreset } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
@@ -20,11 +21,11 @@ import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useFocusedComment from "~/hooks/useFocusedComment";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
@@ -51,11 +52,20 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onHeadingsChange } = props;
const { documents, auth } = useStores();
const {
id,
shareId,
onChange,
onHeadingsChange,
onCreateCommentMark,
onDeleteCommentMark,
} = props;
const { auth, comments, documents } = useStores();
const focusedComment = useFocusedComment();
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const history = useHistory();
const localRef = React.useRef<SharedEditor>();
const preferences = auth.user?.preferences;
const previousHeadings = React.useRef<Heading[] | null>(null);
@@ -63,6 +73,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
activeLinkElement,
setActiveLink,
] = React.useState<HTMLAnchorElement | null>(null);
const previousCommentIds = React.useRef<string[]>();
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
setActiveLink(element);
@@ -125,7 +136,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[documents]
);
const onUploadFile = React.useCallback(
const handleUploadFile = React.useCallback(
async (file: File) => {
const result = await uploadFile(file, {
documentId: id,
@@ -136,7 +147,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[id]
);
const onClickLink = React.useCallback(
const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (isHash(href)) {
@@ -175,7 +186,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
window.open(href, "_blank");
}
},
[shareId]
[history, shareId]
);
const focusAtEnd = React.useCallback(() => {
@@ -223,7 +234,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
uploadFile: handleUploadFile,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
@@ -236,7 +247,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
onUploadFile,
handleUploadFile,
showToast,
]
);
@@ -265,21 +276,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}
}, [localRef, onHeadingsChange]);
const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark) {
const commentMarks = localRef.current?.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
commentIds
);
newCommentIds.forEach((commentId) => {
const mark = commentMarks?.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId);
}
});
const removedCommentIds = difference(
previousCommentIds.current ?? [],
commentMarkIds ?? []
);
removedCommentIds.forEach((commentId) => {
onDeleteCommentMark(commentId);
});
previousCommentIds.current = commentMarkIds;
}
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
updateComments();
},
[onChange, updateHeadings]
[onChange, updateComments, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node) {
updateHeadings();
updateComments();
}
},
[updateHeadings]
[updateComments, updateHeadings]
);
return (
@@ -287,18 +331,19 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
<LazyLoadedEditor
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={onUploadFile}
uploadFile={handleUploadFile}
onShowToast={showToast}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onClickLink={onClickLink}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
focusedCommentId={focusedComment?.id}
/>
{props.bottomPadding && !props.readOnly && (
<ClickablePadding

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
import DocumentMeta from "~/components/DocumentMeta";
import Editor from "~/components/Editor";
import useStores from "~/hooks/useStores";
@@ -30,10 +30,7 @@ function HoverPreviewDocument({ url, children }: Props) {
{children(
<Content to={document.url}>
<Heading>{document.titleWithDefault}</Heading>
<DocumentMetaWithViews
isDraft={document.isDraft}
document={document}
/>
<DocumentMeta document={document} />
<React.Suspense fallback={<div />}>
<Editor

View File

@@ -121,6 +121,8 @@ export type Props = React.InputHTMLAttributes<
margin?: string | number;
error?: string;
icon?: React.ReactNode;
/* Callback is triggered with the CMD+Enter keyboard combo */
onRequestSubmit?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@@ -147,6 +149,20 @@ function Input(
}
};
const handleKeyDown = (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (ev.key === "Enter" && ev.metaKey) {
if (this.props.onRequestSubmit) {
this.props.onRequestSubmit(ev);
}
}
if (this.props.onKeyDown) {
this.props.onKeyDown(ev);
}
};
const {
type = "text",
icon,
@@ -180,6 +196,7 @@ function Input(
ref={ref as React.RefObject<HTMLTextAreaElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
{...rest}
/>
@@ -188,6 +205,7 @@ function Input(
ref={ref as React.RefObject<HTMLInputElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
type={type}
{...rest}

View File

@@ -0,0 +1,42 @@
import { m, TargetAndTransition } from "framer-motion";
import * as React from "react";
import useComponentSize from "~/hooks/useComponentSize";
type Props = {
/** The children to render */
children: React.ReactNode;
/** Whether to hide overflow. */
hideOverflow?: boolean;
/** A way to calculate height */
componentSizeCalculation?: "clientRectHeight" | "scrollHeight";
/** Optional animation config. */
config?: TargetAndTransition;
/** Optional styles. */
style?: React.CSSProperties;
};
/**
* Automatically animates the height of a container based on it's contents.
*/
export function ResizingHeightContainer(props: Props) {
const { hideOverflow, children, config, style } = props;
const ref = React.useRef<HTMLDivElement>(null);
const { height } = useComponentSize(ref);
return (
<m.div
animate={{
...config,
height: Math.round(height),
}}
style={{
...style,
overflow: hideOverflow ? "hidden" : "inherit",
position: "relative",
}}
>
<div ref={ref}>{children}</div>
</m.div>
);
}

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "~/hooks/useWindowSize";
import { hideScrollbars } from "~/styles";
type Props = React.HTMLAttributes<HTMLDivElement> & {
shadow?: boolean;
@@ -94,16 +95,7 @@ const Wrapper = styled.div<{
}};
transition: box-shadow 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
`
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`}
${(props) => props.$hiddenScrollbars && hideScrollbars()}
`;
export default observer(React.forwardRef(Scrollable));

View File

@@ -35,7 +35,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const { user } = auth;
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;

View File

@@ -3,6 +3,8 @@ import styled from "styled-components";
type Props = {
type?: "secondary" | "tertiary" | "danger";
size?: "large" | "small" | "xsmall";
dir?: "ltr" | "rtl" | "auto";
selectable?: boolean;
weight?: "bold" | "normal";
};
@@ -12,6 +14,7 @@ type Props = {
*/
const Text = styled.p<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "left")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
@@ -35,7 +38,7 @@ const Text = styled.p<Props>`
? "normal"
: "inherit"};
white-space: normal;
user-select: none;
user-select: ${(props) => (props.selectable ? "text" : "none")};
`;
export default Text;

41
app/components/Typing.tsx Normal file
View File

@@ -0,0 +1,41 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
/** The size to render the indicator, defaults to 24px */
size?: number;
};
/**
* A component to show an animated typing indicator.
*/
export default function Typing({ size = 24 }: Props) {
return (
<Wrapper height={size} width={size}>
<Circle cx={size / 4} cy={size / 2} r="2" />
<Circle cx={size / 2} cy={size / 2} r="2" />
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
</Wrapper>
);
}
const Wrapper = styled.svg`
fill: ${(props) => props.theme.textTertiary};
@keyframes blink {
50% {
fill: transparent;
}
}
`;
const Circle = styled.circle`
animation: 1s blink infinite;
&:nth-child(2) {
animation-delay: 250ms;
}
&:nth-child(3) {
animation-delay: 500ms;
}
`;

View File

@@ -6,6 +6,7 @@ import * as React from "react";
import { io, Socket } from "socket.io-client";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
@@ -84,6 +85,7 @@ class WebsocketProvider extends React.Component<Props> {
memberships,
policies,
presence,
comments,
views,
subscriptions,
fileOperations,
@@ -261,6 +263,20 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
comments.add(event);
});
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
comments.add(event);
});
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
comments.inThread(event.modelId).forEach((comment) => {
comments.remove(comment.id);
});
});
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
groups.add(event);
});
@@ -323,6 +339,13 @@ class WebsocketProvider extends React.Component<Props> {
stars.remove(event.modelId);
});
this.socket.on(
"user.typing",
(event: { userId: string; documentId: string; commentId: string }) => {
comments.setTyping(event);
}
);
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on(