feat: Comment resolving (#7115)

This commit is contained in:
Tom Moor
2024-07-02 06:55:16 -04:00
committed by GitHub
parent f34557337d
commit 117c4f5009
38 changed files with 1126 additions and 291 deletions

View File

@@ -0,0 +1,86 @@
import { DoneIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: DocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
comment,
onResolve,
}: {
comment: Comment;
onResolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () => stores.policies.abilities(comment.id).resolve,
perform: async ({ t }) => {
await comment.resolve();
history.replace({
...history.location,
state: null,
});
onResolve();
toast.success(t("Thread resolved"));
},
});
export const unresolveCommentFactory = ({
comment,
onUnresolve,
}: {
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: DocumentSection,
icon: <DoneIcon outline />,
visible: () => stores.policies.abilities(comment.id).unresolve,
perform: async () => {
await comment.unresolve();
history.replace({
...history.location,
state: null,
});
onUnresolve();
},
});

View File

@@ -30,6 +30,7 @@ type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
@@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
});
}
function Template({ items, actions, context, ...menu }: Props) {
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
@@ -124,7 +125,8 @@ function Template({ items, actions, context, ...menu }: Props) {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading"
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
@@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
@@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) {
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
@@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) {
selected={item.selected}
dangerous={item.dangerous}
key={index}
icon={item.icon}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
@@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) {
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={<Title title={item.title} icon={item.icon} />}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
);

View File

@@ -640,27 +640,56 @@ export class Editor extends React.PureComponent<
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
/**
* Remove a specific comment mark from the document.
* Remove all marks related to a specific comment from the document.
*
* @param commentId The id of the comment to remove
*/
public removeComment = (commentId: string) => {
const { state, dispatch } = this.view;
let found = false;
state.doc.descendants((node, pos) => {
if (!node.isInline || found) {
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(mark) =>
mark.type === state.schema.marks.comment &&
mark.attrs.id === commentId
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
found = true;
}
});
};
/**
* Update all marks related to a specific comment in the document.
*
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
const { state, dispatch } = this.view;
state.doc.descendants((node, pos) => {
if (!node.isInline) {
return;
}
const mark = node.marks.find(
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
);
if (mark) {
const from = pos;
const to = pos + node.nodeSize;
const newMark = state.schema.marks.comment.create({
...mark.attrs,
...attrs,
});
dispatch(
state.tr.removeMark(from, to, mark).addMark(from, to, newMark)
);
}
});
};
@@ -808,6 +837,7 @@ const EditorContainer = styled(Styles)<{
css`
#comment-${props.focusedCommentId} {
background: ${transparentize(0.5, props.theme.brand.marine)};
border-bottom: 2px solid ${props.theme.commentMarkBackground};
}
`}

View File

@@ -209,7 +209,7 @@ export default function formattingMenuItems(
tooltip: dictionary.comment,
icon: <CommentIcon />,
label: isCodeBlock ? dictionary.comment : undefined,
active: isMarkActive(schema.marks.comment),
active: isMarkActive(schema.marks.comment, { resolved: false }),
visible: !isMobile || !isEmpty,
},
{

View File

@@ -1,16 +1,22 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import { CopyIcon, EditIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { toast } from "sonner";
import EventBoundary from "@shared/components/EventBoundary";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
deleteCommentFactory,
resolveCommentFactory,
unresolveCommentFactory,
} from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { commentPath, urlify } from "~/utils/routeHelpers";
@@ -24,24 +30,26 @@ type Props = {
onEdit: () => void;
/** Callback when the comment has been deleted */
onDelete: () => void;
/** Callback when the comment has been updated */
onUpdate: (attrs: { resolved: boolean }) => void;
};
function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
function CommentMenu({
comment,
onEdit,
onDelete,
onUpdate,
className,
}: Props) {
const menu = useMenuState({
modal: true,
});
const { documents, dialogs } = useStores();
const { documents } = useStores();
const { t } = useTranslation();
const can = usePolicy(comment);
const context = useActionContext({ isContextMenu: true });
const document = documents.get(comment.documentId);
const handleDelete = React.useCallback(() => {
dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
}, [dialogs, comment, onDelete, t]);
const handleCopyLink = React.useCallback(() => {
if (document) {
copy(urlify(commentPath(document, comment)));
@@ -58,24 +66,46 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
{...menu}
/>
</EventBoundary>
<ContextMenu {...menu} aria-label={t("Comment options")}>
{can.update && (
<MenuItem {...menu} onClick={onEdit}>
{t("Edit")}
</MenuItem>
)}
<MenuItem {...menu} onClick={handleCopyLink}>
{t("Copy link")}
</MenuItem>
{can.delete && (
<>
<Separator />
<MenuItem {...menu} onClick={handleDelete} dangerous>
{t("Delete")}
</MenuItem>
</>
)}
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update,
},
actionToMenuItem(
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
context
),
actionToMenuItem(
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
context
),
{
type: "button",
icon: <CopyIcon />,
title: t("Copy link"),
onClick: handleCopyLink,
},
{
type: "separator",
},
actionToMenuItem(
deleteCommentFactory({ comment, onDelete }),
context
),
]}
/>
</ContextMenu>
</>
);

View File

@@ -3,6 +3,7 @@ import { computed, observable } from "mobx";
import { now } from "mobx-utils";
import type { ProsemirrorData } from "@shared/types";
import User from "~/models/User";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
@@ -34,7 +35,7 @@ class Comment extends Model {
*/
@Field
@observable
parentCommentId: string;
parentCommentId: string | null;
/**
* The comment that this comment is a reply to.
@@ -43,33 +44,86 @@ class Comment extends Model {
parentComment?: Comment;
/**
* The document to which this comment belongs.
* The document ID to which this comment belongs.
*/
@Field
@observable
documentId: string;
/**
* The document that this comment belongs to.
*/
@Relation(() => Document, { onDelete: "cascade" })
document: Document;
/**
* The user who created this comment.
*/
@Relation(() => User)
createdBy: User;
/**
* The ID of the user who created this comment.
*/
createdById: string;
/**
* The date and time that this comment was resolved, if it has been resolved.
*/
@observable
resolvedAt: string;
/**
* The user who resolved this comment, if it has been resolved.
*/
@Relation(() => User)
resolvedBy: User;
resolvedBy: User | null;
/**
* The ID of the user who resolved this comment, if it has been resolved.
*/
resolvedById: string | null;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@computed
get currentlyTypingUsers(): User[] {
public get currentlyTypingUsers(): User[] {
return Array.from(this.typingUsers.entries())
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
.map(([userId]) => this.store.rootStore.users.get(userId))
.filter(Boolean) as User[];
}
/**
* Whether the comment is resolved
*/
@computed
public get isResolved() {
return !!this.resolvedAt;
}
/**
* Whether the comment is a reply to another comment.
*/
@computed
public get isReply() {
return !!this.parentCommentId;
}
/**
* Resolve the comment
*/
public resolve() {
return this.store.rootStore.comments.resolve(this.id);
}
/**
* Unresolve the comment
*/
public unresolve() {
return this.store.rootStore.comments.unresolve(this.id);
}
}
export default Comment;

View File

@@ -106,6 +106,7 @@ function CommentForm({
thread ??
new Comment(
{
createdAt: new Date().toISOString(),
documentId,
data: draft,
},
@@ -139,6 +140,7 @@ function CommentForm({
const comment = new Comment(
{
createdAt: new Date().toISOString(),
parentCommentId: thread?.id,
documentId,
data: draft,

View File

@@ -2,7 +2,7 @@ import throttle from "lodash/throttle";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useHistory, useLocation } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -70,6 +70,7 @@ function CommentThread({
const user = useCurrentUser();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const [, setIsTyping] = useTypingIndicator({
document,
@@ -92,7 +93,8 @@ function CommentThread({
!(event.target as HTMLElement).classList.contains("comment")
) {
history.replace({
pathname: window.location.pathname,
search: location.search,
pathname: location.pathname,
state: { commentId: undefined },
});
}
@@ -100,7 +102,8 @@ function CommentThread({
const handleClickThread = () => {
history.replace({
pathname: window.location.pathname.replace(/\/history$/, ""),
search: location.search,
pathname: location.pathname.replace(/\/history$/, ""),
state: { commentId: thread.id },
});
};
@@ -177,6 +180,7 @@ function CommentThread({
highlightedText={index === 0 ? highlightedText : undefined}
comment={comment}
onDelete={() => editor?.removeComment(comment.id)}
onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)}
key={comment.id}
firstOfThread={index === 0}
lastOfThread={index === commentsInThread.length - 1 && !draft}

View File

@@ -7,6 +7,7 @@ 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 } from "@shared/styles";
import { ProsemirrorData } from "@shared/types";
import { dateToRelative } from "@shared/utils/date";
@@ -76,6 +77,8 @@ type Props = {
canReply: boolean;
/** Callback when the comment has been deleted */
onDelete: () => void;
/** Callback when the comment has been updated */
onUpdate: (attrs: { resolved: boolean }) => void;
/** Text to highlight at the top of the comment */
highlightedText?: string;
};
@@ -89,6 +92,7 @@ function CommentThreadItem({
previousCommentCreatedAt,
canReply,
onDelete,
onUpdate,
highlightedText,
}: Props) {
const { t } = useTranslation();
@@ -97,7 +101,9 @@ function CommentThreadItem({
const showAuthor = firstOfAuthor;
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
const showEdited =
comment.updatedAt && comment.updatedAt !== comment.createdAt;
comment.updatedAt &&
comment.updatedAt !== comment.createdAt &&
!comment.isResolved;
const [isEditing, setEditing, setReadOnly] = useBoolean();
const formRef = React.useRef<HTMLFormElement>(null);
@@ -198,14 +204,17 @@ function CommentThreadItem({
</Flex>
)}
</Body>
{!isEditing && (
<Menu
comment={comment}
onEdit={setEditing}
onDelete={onDelete}
dir={dir}
/>
)}
<EventBoundary>
{!isEditing && (
<Menu
comment={comment}
onEdit={setEditing}
onDelete={onDelete}
onUpdate={onUpdate}
dir={dir}
/>
)}
</EventBoundary>
</Bubble>
</Flex>
);

View File

@@ -1,19 +1,25 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import styled, { css } from "styled-components";
import { ProsemirrorData } from "@shared/types";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { bigPulse } from "~/styles/animations";
import CommentForm from "./CommentForm";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";
@@ -22,7 +28,11 @@ function Comments() {
const { ui, comments, documents } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const location = useLocation();
const history = useHistory();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const [pulse, setPulse] = React.useState(false);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
@@ -34,18 +44,75 @@ function Comments() {
undefined
);
const viewingResolved = params.get("resolved") === "";
const resolvedThreads = document
? comments.resolvedThreadsInDocument(document.id)
: [];
const resolvedThreadsCount = resolvedThreads.length;
React.useEffect(() => {
setPulse(true);
const timeout = setTimeout(() => setPulse(false), 250);
return () => {
clearTimeout(timeout);
setPulse(false);
};
}, [resolvedThreadsCount]);
if (!document) {
return null;
}
const threads = comments
.threadsInDocument(document.id)
.filter((thread) => !thread.isNew || thread.createdById === user.id);
const threads = (
viewingResolved
? resolvedThreads
: comments.unresolvedThreadsInDocument(document.id)
).filter((thread) => thread.createdById === user.id);
const hasComments = threads.length > 0;
const toggleViewingResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: viewingResolved ? undefined : "",
}),
pathname: location.pathname,
});
};
return (
<Sidebar
title={t("Comments")}
title={
<Flex align="center" justify="space-between" auto>
{viewingResolved ? (
<React.Fragment key="resolved">
<span>{t("Resolved comments")}</span>
<Tooltip delay={500} content={t("View comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon />}
onClick={toggleViewingResolved}
/>
</Tooltip>
</React.Fragment>
) : (
<React.Fragment>
<span>{t("Comments")}</span>
<Tooltip delay={250} content={t("View resolved comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon outline />}
onClick={toggleViewingResolved}
$pulse={pulse}
/>
</Tooltip>
</React.Fragment>
)}
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
scrollable={false}
>
@@ -68,13 +135,17 @@ function Comments() {
))
) : (
<NoComments align="center" justify="center" auto>
<PositionedEmpty>{t("No comments yet")}</PositionedEmpty>
<PositionedEmpty>
{viewingResolved
? t("No resolved comments")
: t("No comments yet")}
</PositionedEmpty>
</NoComments>
)}
</Wrapper>
</Scrollable>
<AnimatePresence initial={false}>
{!focusedComment && can.comment && (
{!focusedComment && can.comment && !viewingResolved && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
@@ -91,6 +162,14 @@ function Comments() {
);
}
const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
${(props) =>
props.$pulse &&
css`
animation: ${bigPulse} 250ms 1;
`}
`;
const PositionedEmpty = styled(Empty)`
position: absolute;
top: calc(50vh - 30px);

View File

@@ -186,7 +186,7 @@ function DataLoader({ match, children }: Props) {
// when viewing a public share link
if (can.read && !document.isDeleted) {
if (team.getPreference(TeamPreference.Commenting)) {
void comments.fetchPage({
void comments.fetchAll({
documentId: document.id,
limit: 100,
});

View File

@@ -37,7 +37,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
const insightsPath = documentInsightsPath(document);
const commentsCount = comments.filter({ documentId: document.id }).length;
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
return (
<Meta document={document} revision={revision} to={to} replace {...rest}>

View File

@@ -13,7 +13,7 @@ import useMobile from "~/hooks/useMobile";
import { draggableOnDesktop } from "~/styles";
import { fadeIn } from "~/styles/animations";
type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> & {
/* The title of the sidebar */
title: React.ReactNode;
/* The content of the sidebar */

View File

@@ -114,6 +114,19 @@ function KeyboardShortcuts() {
},
],
},
{
title: t("Collaboration"),
items: [
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key>Alt</Key> + <Key>m</Key>
</>
),
label: t("Comment"),
},
],
},
{
title: t("Formatting"),
items: [

View File

@@ -1,6 +1,8 @@
import invariant from "invariant";
import orderBy from "lodash/orderBy";
import { action, computed } from "mobx";
import Comment from "~/models/Comment";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
@@ -29,12 +31,54 @@ export default class CommentsStore extends Store<Comment> {
threadsInDocument(documentId: string): Comment[] {
return this.filter(
(comment: Comment) =>
comment.documentId === documentId && !comment.parentCommentId
comment.documentId === documentId &&
!comment.parentCommentId &&
(!comment.isNew ||
comment.createdById === this.rootStore.auth.currentUserId)
);
}
/**
* Returns a list of comments that are replies to the given comment.
* Returns a list of resolved comments in a document that are not replies to other
* comments.
*
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
resolvedThreadsInDocument(documentId: string): Comment[] {
return this.threadsInDocument(documentId).filter(
(comment: Comment) => comment.isResolved === true
);
}
/**
* Returns a list of comments in a document that are not replies to other
* comments.
*
* @param documentId ID of the document to get comments for
* @returns Array of comments
*/
unresolvedThreadsInDocument(documentId: string): Comment[] {
return this.threadsInDocument(documentId).filter(
(comment: Comment) => comment.isResolved === false
);
}
/**
* Returns the total number of unresolbed comments in the given document.
*
* @param documentId ID of the document to get comments for
* @returns A number of comments
*/
unresolvedCommentsInDocumentCount(documentId: string): number {
return this.unresolvedThreadsInDocument(documentId).reduce(
(memo, thread) => memo + this.inThread(thread.id).length,
0
);
}
/**
* Returns a list of comments that includes the given thread ID and any of it's replies.
*
* @param commentId ID of the comment to get replies for
* @returns Array of comments
@@ -46,6 +90,40 @@ export default class CommentsStore extends Store<Comment> {
);
}
/**
* Resolve a comment thread with the given ID.
*
* @param id ID of the comment to resolve
* @returns Resolved comment
*/
@action
resolve = async (id: string): Promise<Comment> => {
const res = await client.post("/comments.resolve", {
id,
});
invariant(res?.data, "Comment not available");
this.addPolicies(res.policies);
this.add(res.data);
return this.data.get(res.data.id) as Comment;
};
/**
* Unresolve a comment thread with the given ID.
*
* @param id ID of the comment to unresolve
* @returns Unresolved comment
*/
@action
unresolve = async (id: string): Promise<Comment> => {
const res = await client.post("/comments.unresolve", {
id,
});
invariant(res?.data, "Comment not available");
this.addPolicies(res.policies);
this.add(res.data);
return this.data.get(res.data.id) as Comment;
};
@action
setTyping({
commentId,

View File

@@ -116,6 +116,12 @@ export const pulse = keyframes`
100% { transform: scale(1); }
`;
export const bigPulse = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
`;
/**
* The duration of the sidebar appearing animation in ms
*/