feat: Comment reactions (#7790)

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Hemachandar
2024-11-02 23:28:03 +05:30
committed by GitHub
parent d87e1f6264
commit de04d1c0c5
64 changed files with 1856 additions and 101 deletions
+26 -1
View File
@@ -1,9 +1,10 @@
import { DoneIcon, TrashIcon } from "outline-icons";
import { DoneIcon, SmileyIcon, 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 ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { DocumentSection } from "../sections";
@@ -88,3 +89,27 @@ export const unresolveCommentFactory = ({
onUnresolve();
},
});
export const viewCommentReactionsFactory = ({
comment,
}: {
comment: Comment;
}) =>
createAction({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: DocumentSection,
icon: <SmileyIcon />,
visible: () =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Reactions"),
content: <ViewReactionsDialog model={comment} />,
});
},
});
+1 -1
View File
@@ -226,7 +226,7 @@ const Input = styled.div`
}
&[data-editing="true"] {
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
+20
View File
@@ -0,0 +1,20 @@
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
/** Width of the containing element. */
width?: number | string;
/** Height of the containing element. */
height?: number | string;
/** Controls the rendered emoji size. */
size?: number;
};
export const Emoji = styled.span<Props>`
font-family: ${s("fontFamilyEmoji")};
width: ${({ width }) =>
typeof width === "string" ? width : width ? `${width}px` : "auto"};
height: ${({ height }) =>
typeof height === "string" ? height : height ? `${height}px` : "auto"};
font-size: ${({ size }) => size && `${size}px`};
`;
+1 -1
View File
@@ -138,7 +138,7 @@ class ErrorBoundary extends React.Component<Props> {
}
const Pre = styled.pre`
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
padding: 16px;
border-radius: 4px;
font-size: 12px;
+1 -1
View File
@@ -75,7 +75,7 @@ const Image = styled(Flex)`
justify-content: center;
width: 32px;
height: 32px;
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
border-radius: 32px;
`;
+1 -1
View File
@@ -61,7 +61,7 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
background-color: ${(props) =>
props.color ?? props.theme.secondaryBackground};
props.color ?? props.theme.backgroundSecondary};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
width: fit-content;
@@ -1,8 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
export const Emoji = styled.span`
font-family: ${s("fontFamilyEmoji")};
width: 24px;
height: 24px;
`;
@@ -4,9 +4,9 @@ import React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Emoji } from "~/components/Emoji";
import Text from "~/components/Text";
import { TRANSLATED_CATEGORIES } from "../utils";
import { Emoji } from "./Emoji";
import Grid from "./Grid";
import { IconButton } from "./IconButton";
@@ -85,7 +85,9 @@ const GridTemplate = (
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
>
<Emoji>{item.value}</Emoji>
<Emoji width={24} height={24}>
{item.value}
</Emoji>
</IconButton>
);
});
@@ -5,10 +5,10 @@ import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
import { Emoji } from "./Emoji";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
@@ -26,7 +26,7 @@ const SkinTonePicker = ({
);
const menu = useMenuState({
placement: "bottom",
placement: "bottom-end",
});
const handleSkinClick = React.useCallback(
@@ -43,7 +43,9 @@ const SkinTonePicker = ({
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji>{emoji.value}</Emoji>
<Emoji width={24} height={24}>
{emoji.value}
</Emoji>
</IconButton>
)}
</MenuItem>
+1 -1
View File
@@ -192,7 +192,7 @@ const Wrapper = styled.a<{
&:focus,
&:focus-within {
background: ${(props) =>
props.$hover ? props.theme.secondaryBackground : "inherit"};
props.$hover ? props.theme.backgroundSecondary : "inherit"};
}
cursor: ${(props) =>
+173
View File
@@ -0,0 +1,173 @@
import { observer } from "mobx-react";
import { transparentize } from "polished";
import React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type { ReactionSummary } from "@shared/types";
import { getEmojiId } from "@shared/utils/emoji";
import User from "~/models/User";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { hover } from "~/styles";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
reaction: ReactionSummary;
/** Users who reacted using this emoji. */
reactedUsers: User[];
/** Whether the emoji button should be disabled (prevents add/remove events). */
disabled: boolean;
/** Callback when the user intends to add the reaction. */
onAddReaction: (emoji: string) => Promise<void>;
/** Callback when the user intends to remove the reaction. */
onRemoveReaction: (emoji: string) => Promise<void>;
};
const useTooltipContent = ({
reactedUsers,
currUser,
emoji,
active,
}: {
reactedUsers: User[];
currUser: User;
emoji: string;
active: boolean;
}) => {
const { t } = useTranslation();
if (!reactedUsers.length) {
return;
}
const transformedEmoji = `:${getEmojiId(emoji)}:`;
switch (reactedUsers.length) {
case 1: {
return t("{{ username }} reacted with {{ emoji }}", {
username: active ? t("You") : reactedUsers[0].name,
emoji: transformedEmoji,
});
}
case 2: {
const firstUsername = active ? t("You") : reactedUsers[0].name;
const secondUsername = active
? reactedUsers.find((user) => user.id !== currUser.id)?.name
: reactedUsers[1].name;
return t(
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
{
firstUsername,
secondUsername,
emoji: transformedEmoji,
}
);
}
default: {
const firstUsername = active ? t("You") : reactedUsers[0].name;
const count = reactedUsers.length - 1;
return t(
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
{
firstUsername,
count,
emoji: transformedEmoji,
}
);
}
}
};
const Reaction: React.FC<Props> = ({
reaction,
reactedUsers,
disabled,
onAddReaction,
onRemoveReaction,
}) => {
const user = useCurrentUser();
const active = reaction.userIds.includes(user.id);
const tooltipContent = useTooltipContent({
reactedUsers,
currUser: user,
emoji: reaction.emoji,
active,
});
const handleClick = React.useCallback(
(event: React.SyntheticEvent<HTMLButtonElement>) => {
event.stopPropagation();
active
? void onRemoveReaction(reaction.emoji)
: void onAddReaction(reaction.emoji);
},
[reaction, active, onAddReaction, onRemoveReaction]
);
const DisplayedEmoji = React.useMemo(
() => (
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
<Flex gap={6} justify="center" align="center">
<Emoji size={15}>{reaction.emoji}</Emoji>
<Count weight="xbold">{reaction.userIds.length}</Count>
</Flex>
</EmojiButton>
),
[reaction.emoji, reaction.userIds, disabled, active, handleClick]
);
return tooltipContent ? (
<Tooltip content={tooltipContent} delay={250} placement="bottom">
{DisplayedEmoji}
</Tooltip>
) : (
<>{DisplayedEmoji}</>
);
};
const EmojiButton = styled(NudeButton)<{
$active: boolean;
disabled: boolean;
}>`
width: auto;
height: 28px;
padding: 6px;
border-radius: 12px;
transition: ${s("backgroundTransition")};
background: ${s("backgroundTertiary")};
pointer-events: ${({ disabled }) => disabled && "none"};
&: ${hover} {
background: ${s("backgroundQuaternary")};
}
${(props) =>
props.$active &&
css`
background: ${transparentize(0.7, props.theme.accent)};
&: ${hover} {
background: ${transparentize(0.5, props.theme.accent)};
}
`}
`;
const Count = styled(Text)`
font-size: 11px;
color: ${s("buttonNeutralText")};
padding-right: 1px;
font-variant-numeric: tabular-nums;
`;
export default observer(Reaction);
+87
View File
@@ -0,0 +1,87 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import React from "react";
import Comment from "~/models/Comment";
import useHover from "~/hooks/useHover";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import Flex from "../Flex";
import { ResizingHeightContainer } from "../ResizingHeightContainer";
import Reaction from "./Reaction";
type Props = {
/** Model for which to show the reactions. */
model: Comment;
/** Callback when the user intends to add a reaction. */
onAddReaction: (emoji: string) => Promise<void>;
/** Callback when the user intends to remove a reaction. */
onRemoveReaction: (emoji: string) => Promise<void>;
/** classname generated by styled-components. */
className?: string;
/** Picker to render as the last element */
picker?: React.ReactElement;
};
const ReactionList: React.FC<Props> = ({
model,
onAddReaction,
onRemoveReaction,
className,
picker,
}) => {
const { users } = useStores();
const listRef = React.useRef<HTMLDivElement>(null);
const hovered = useHover({
ref: listRef,
duration: 250,
});
React.useEffect(() => {
const loadReactedUsersData = async () => {
try {
await model.loadReactedUsersData();
} catch (err) {
Logger.warn("Could not prefetch reaction data");
}
};
if (hovered) {
void loadReactedUsersData();
}
}, [hovered, model]);
const hasReactions = !!model.reactions.length;
const style = React.useMemo(() => {
if (hasReactions) {
return { minHeight: 28 };
}
return undefined;
}, [hasReactions]);
return (
<ResizingHeightContainer style={style}>
<Flex ref={listRef} className={className} align="center" gap={6} wrap>
{model.reactions.map((reaction) => {
const reactedUsers = compact(
reaction.userIds.map((id) => users.get(id))
);
return (
<Reaction
key={reaction.emoji}
reaction={reaction}
reactedUsers={reactedUsers}
disabled={model.isResolved}
onAddReaction={onAddReaction}
onRemoveReaction={onRemoveReaction}
/>
);
})}
{picker}
</Flex>
</ResizingHeightContainer>
);
};
export default observer(ReactionList);
+161
View File
@@ -0,0 +1,161 @@
import { ReactionIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import styled from "styled-components";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
);
type Props = {
/** Callback when an emoji is selected by the user. */
onSelect: (emoji: string) => Promise<void>;
/** Callback when the picker is opened. */
onOpen?: () => void;
/** Callback when the picker is closed. */
onClose?: () => void;
/** Optional classname. */
className?: string;
size?: number;
};
const ReactionPicker: React.FC<Props> = ({
onSelect,
onOpen,
onClose,
className,
size,
}) => {
const { t } = useTranslation();
const popover = usePopoverState({
modal: true,
unstable_offset: [0, 0],
placement: "bottom-end",
});
const { width: windowWidth } = useWindowSize();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const contentRef = React.useRef<HTMLDivElement | null>(null);
const popoverWidth = isMobile ? windowWidth : 300;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
const { toggle, hide } = popover;
const handlePopoverButtonClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
toggle();
},
[toggle]
);
const handleEmojiSelect = React.useCallback(
(emoji: string) => {
hide();
void onSelect(emoji);
},
[hide, onSelect]
);
// Popover open effect
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (
popover.visible &&
!popover.unstable_disclosureRef.current?.contains(event.target as Node)
) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
>
<ReactionIcon size={22} />
</PopoverButton>
)}
</PopoverDisclosure>
<Popover
{...popover}
ref={contentRef}
width={popoverWidth}
shrink
aria-label={t("Reaction picker")}
onClick={(e) => e.stopPropagation()}
hideOnClickOutside={false}
>
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<ScrollableContainer>
<EmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={true}
onEmojiChange={handleEmojiSelect}
onQueryChange={setQuery}
/>
</ScrollableContainer>
</React.Suspense>
)}
</Popover>
</>
);
};
const Placeholder = React.memo(
() => (
<Flex column gap={6} style={{ height: "300px", padding: "6px 12px" }}>
<Flex gap={8}>
<PlaceholderText height={32} minWidth={90} />
<PlaceholderText height={32} width={32} />
</Flex>
<PlaceholderText height={24} width={120} />
</Flex>
),
() => true
);
Placeholder.displayName = "ReactionPickerPlaceholder";
const ScrollableContainer = styled.div`
height: 300px;
overflow-y: auto;
`;
const PopoverButton = styled(NudeButton)`
border-radius: 50%;
`;
export default ReactionPicker;
@@ -0,0 +1,146 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Tab, TabPanel, useTabState } from "reakit";
import { toast } from "sonner";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Comment from "~/models/Comment";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
type Props = {
/** Model for which to show the reactions. */
model: Comment;
};
const ViewReactionsDialog: React.FC<Props> = ({ model }) => {
const { t } = useTranslation();
const { users } = useStores();
const tab = useTabState();
const { reactedUsersLoaded } = model;
React.useEffect(() => {
const loadReactedUsersData = async () => {
try {
await model.loadReactedUsersData();
} catch (err) {
toast.error(t("Could not load reactions"));
}
};
void loadReactedUsersData();
}, [t, model]);
if (!reactedUsersLoaded) {
return <PlaceHolder />;
}
return (
<>
<TabActionsWrapper>
{model.reactions.map((reaction) => (
<StyledTab
{...tab}
key={reaction.emoji}
id={reaction.emoji}
aria-label={t("Reaction")}
$active={tab.selectedId === reaction.emoji}
>
<Emoji size={16}>{reaction.emoji}</Emoji>
</StyledTab>
))}
</TabActionsWrapper>
{model.reactions.map((reaction) => {
const reactedUsers = compact(
reaction.userIds.map((id) => users.get(id))
);
return (
<StyledTabPanel {...tab} key={reaction.emoji}>
{reactedUsers.map((user) => (
<UserInfo key={user.name} align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Medium} />
<Text size="medium">{user.name}</Text>
</UserInfo>
))}
</StyledTabPanel>
);
})}
</>
);
};
const PlaceHolder = React.memo(
() => (
<>
<TabActionsWrapper gap={8} style={{ paddingBottom: "10px" }}>
<PlaceholderText width={40} height={32} />
<PlaceholderText width={40} height={32} />
</TabActionsWrapper>
<UserInfo align="center" gap={12}>
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
<PlaceholderText height={34} />
</UserInfo>
<UserInfo align="center" gap={12}>
<PlaceholderText width={AvatarSize.Medium} height={AvatarSize.Medium} />
<PlaceholderText height={34} />
</UserInfo>
</>
),
() => true
);
PlaceHolder.displayName = "ViewReactionsPlaceholder";
const TabActionsWrapper = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const StyledTab = styled(Tab)<{ $active: boolean }>`
position: relative;
font-weight: 500;
font-size: 14px;
cursor: var(--pointer);
background: none;
border: 0;
border-radius: 4px 4px 0 0;
padding: 8px 12px 10px;
user-select: none;
transition: background-color 100ms ease;
&: ${hover} {
background-color: ${s("listItemHoverBackground")};
}
${({ $active }) =>
$active &&
css`
&:after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${s("textSecondary")};
}
`}
`;
const StyledTabPanel = styled(TabPanel)`
height: 300px;
padding: 5px 0;
overflow-y: auto;
`;
const UserInfo = styled(Flex)`
padding: 10px 8px;
`;
export default observer(ViewReactionsDialog);
+1 -1
View File
@@ -253,7 +253,7 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
&:hover {
background: ${(props) =>
props.$sortable ? props.theme.secondaryBackground : "none"};
props.$sortable ? props.theme.backgroundSecondary : "none"};
}
`;
+25
View File
@@ -27,6 +27,7 @@ import withStores from "~/components/withStores";
import {
PartialExcept,
WebsocketCollectionUpdateIndexEvent,
WebsocketCommentReactionEvent,
WebsocketEntitiesEvent,
WebsocketEntityDeletedEvent,
} from "~/types";
@@ -351,6 +352,30 @@ class WebsocketProvider extends React.Component<Props> {
comments.remove(event.modelId);
});
this.socket.on(
"comments.add_reaction",
(event: WebsocketCommentReactionEvent) => {
const comment = comments.get(event.commentId);
comment?.updateReaction({
type: "add",
emoji: event.emoji,
user: event.user,
});
}
);
this.socket.on(
"comments.remove_reaction",
(event: WebsocketCommentReactionEvent) => {
const comment = comments.get(event.commentId);
comment?.updateReaction({
type: "remove",
emoji: event.emoji,
user: event.user,
});
}
);
this.socket.on("groups.create", (event: PartialExcept<Group, "id">) => {
groups.add(event);
});
+50
View File
@@ -0,0 +1,50 @@
import React from "react";
import useUnmount from "./useUnmount";
type Props = {
/** Ref to the element that needs to be observed. */
ref: React.RefObject<HTMLElement>;
/** Duration to wait until it's considered as a hover event. */
duration: number;
};
/**
* Hook that will trigger the first time an element is hovered.
*
* @returns {boolean} hovered - Signals when an element is hovered by the user.
*/
const useHover = ({ ref, duration }: Props): boolean => {
const [hovered, setHovered] = React.useState(false);
const timer = React.useRef<number>();
const onMouseEnter = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = window.setTimeout(() => setHovered(true), duration);
}, [duration]);
const onMouseLeave = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
}, []);
useUnmount(() => {
if (timer.current) {
clearTimeout(timer.current);
}
});
React.useEffect(() => {
if (ref.current) {
ref.current.onmouseenter = onMouseEnter;
ref.current.onmouseleave = onMouseLeave;
}
}, [ref, onMouseEnter, onMouseLeave]);
return hovered;
};
export default useHover;
+50 -41
View File
@@ -15,6 +15,7 @@ import {
deleteCommentFactory,
resolveCommentFactory,
unresolveCommentFactory,
viewCommentReactionsFactory,
} from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
@@ -66,47 +67,55 @@ function CommentMenu({
{...menu}
/>
</EventBoundary>
<ContextMenu {...menu} aria-label={t("Comment options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update && !comment.isResolved,
},
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>
{menu.visible && (
<ContextMenu {...menu} aria-label={t("Comment options")}>
<Template
{...menu}
items={[
{
type: "button",
title: `${t("Edit")}`,
icon: <EditIcon />,
onClick: onEdit,
visible: can.update && !comment.isResolved,
},
actionToMenuItem(
resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
context
),
actionToMenuItem(
unresolveCommentFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
context
),
actionToMenuItem(
viewCommentReactionsFactory({
comment,
}),
context
),
{
type: "button",
icon: <CopyIcon />,
title: t("Copy link"),
onClick: handleCopyLink,
},
{
type: "separator",
},
actionToMenuItem(
deleteCommentFactory({ comment, onDelete }),
context
),
]}
/>
</ContextMenu>
)}
</>
);
}
+175 -2
View File
@@ -1,8 +1,12 @@
import { subSeconds } from "date-fns";
import { computed, observable } from "mobx";
import invariant from "invariant";
import uniq from "lodash/uniq";
import { action, computed, observable } from "mobx";
import { now } from "mobx-utils";
import type { ProsemirrorData } from "@shared/types";
import { Pagination } from "@shared/constants";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import User from "~/models/User";
import { client } from "~/utils/ApiClient";
import Document from "./Document";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -84,6 +88,25 @@ class Comment extends Model {
*/
resolvedById: string | null;
/**
* Active reactions for this comment.
*
* Note: This contains just the emoji with the associated user-ids.
*/
@observable
reactions: ReactionSummary[];
/**
* Denotes whether the user data for the active reactions are loaded.
*/
@observable
reactedUsersLoaded: boolean = false;
/**
* Denotes whether there is an in-flight request for loading reacted users.
*/
private reactedUsersLoading = false;
/**
* An array of users that are currently typing a reply in this comments thread.
*/
@@ -124,6 +147,156 @@ class Comment extends Model {
public unresolve() {
return this.store.rootStore.comments.unresolve(this.id);
}
/**
* Add an emoji as a reaction to this comment.
*
* Optimistically updates the `reactions` cache and invokes the backend API.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.emoji - The emoji to add as a reaction.
* @param {string} reaction.user - The user who added this reaction.
*/
@action
public addReaction = async ({
emoji,
user,
}: {
emoji: string;
user: User;
}) => {
this.updateReaction({ type: "add", emoji, user });
try {
await client.post("/comments.add_reaction", {
id: this.id,
emoji,
});
} catch {
this.updateReaction({ type: "remove", emoji, user });
}
};
/**
* Remove an emoji as a reaction from this comment.
*
* Optimistically updates the `reactions` cache and invokes the backend API.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.emoji - The emoji to remove as a reaction.
* @param {string} reaction.user - The user who removed this reaction.
*/
@action
public removeReaction = async ({
emoji,
user,
}: {
emoji: string;
user: User;
}) => {
this.updateReaction({ type: "remove", emoji, user });
try {
await client.post("/comments.remove_reaction", {
id: this.id,
emoji,
});
} catch {
this.updateReaction({ type: "add", emoji, user });
}
};
/**
* Update the `reactions` cache.
*
* @param {Object} reaction - The reaction data.
* @param {string} reaction.type - The type of the action.
* @param {string} reaction.emoji - The emoji to update as a reaction.
* @param {string} reaction.user - The user who performed this action.
*/
@action
public updateReaction = ({
type,
emoji,
user,
}: {
type: "add" | "remove";
emoji: string;
user: User;
}) => {
const reaction = this.reactions.find((r) => r.emoji === emoji);
// Step 1: Update the reactions cache.
if (type === "add") {
if (!reaction) {
this.reactions.push({ emoji, userIds: [user.id] });
} else {
reaction.userIds = uniq([...reaction.userIds, user.id]);
}
} else {
if (reaction) {
reaction.userIds = reaction.userIds.filter((id) => id !== user.id);
}
if (reaction?.userIds.length === 0) {
this.reactions = this.reactions.filter(
(r) => r.emoji !== reaction.emoji
);
}
}
// Step 2: Add the user to the store.
this.store.rootStore.users.add(user);
};
/**
* Load the users for the active reactions.
*
*
* @param {Object} options - Options for loading the data.
* @param {string} options.limit - Per request limit for pagination.
*/
@action
loadReactedUsersData = async (
{ limit }: { limit: number } = { limit: Pagination.defaultLimit }
) => {
if (this.reactedUsersLoading || this.reactedUsersLoaded) {
return;
}
this.reactedUsersLoading = true;
try {
const fetchPage = async (offset: number = 0) => {
const res = await client.post("/reactions.list", {
commentId: this.id,
offset,
limit,
});
invariant(res?.data, "Data not available");
// @ts-expect-error reaction from server response
res.data.map((reaction) =>
this.store.rootStore.users.add(reaction.user)
);
return res.pagination;
};
const { total } = await fetchPage();
const pages = Math.ceil(total / limit);
const fetchPages = [];
for (let page = 1; page < pages; page++) {
fetchPages.push(fetchPage(page * limit));
}
await Promise.all(fetchPages);
this.reactedUsersLoaded = true;
} finally {
this.reactedUsersLoading = false;
}
};
}
export default Comment;
@@ -109,6 +109,7 @@ function CommentForm({
createdAt: new Date().toISOString(),
documentId,
data: draft,
reactions: [],
},
comments
);
@@ -144,6 +145,7 @@ function CommentForm({
parentCommentId: thread?.id,
documentId,
data: draft,
reactions: [],
},
comments
);
@@ -36,12 +36,16 @@ type Props = {
focused: boolean;
/** Whether the thread is displayed in a recessed/backgrounded state */
recessed: boolean;
/** Enable scroll for the comments container */
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
};
function useTypingIndicator({
document,
comment,
}: Omit<Props, "focused" | "recessed">): [undefined, () => void] {
}: Pick<Props, "document" | "comment">): [undefined, () => void] {
const socket = React.useContext(WebsocketContext);
const setIsTyping = React.useMemo(
@@ -63,6 +67,8 @@ function CommentThread({
document,
recessed,
focused,
enableScroll,
disableScroll,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const { editor } = useDocumentContext();
@@ -202,6 +208,8 @@ function CommentThread({
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
enableScroll={enableScroll}
disableScroll={disableScroll}
/>
);
})}
@@ -16,9 +16,12 @@ import Comment from "~/models/Comment";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import Flex from "~/components/Flex";
import ReactionList from "~/components/Reactions/ReactionList";
import ReactionPicker from "~/components/Reactions/ReactionPicker";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
import { hover } from "~/styles";
import CommentEditor from "./CommentEditor";
@@ -81,6 +84,10 @@ type Props = {
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** Enable scroll for the comments container */
enableScroll: () => void;
/** Disable scroll for the comments container */
disableScroll: () => void;
};
function CommentThreadItem({
@@ -94,8 +101,11 @@ function CommentThreadItem({
onDelete,
onUpdate,
highlightedText,
enableScroll,
disableScroll,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const [data, setData] = React.useState(comment.data);
const showAuthor = firstOfAuthor;
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
@@ -106,6 +116,20 @@ function CommentThreadItem({
const [isEditing, setEditing, setReadOnly] = useBoolean();
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);
@@ -210,16 +234,43 @@ function CommentThreadItem({
</ButtonSmall>
</Flex>
)}
{!!comment.reactions.length && (
<ReactionListContainer gap={6} align="center">
<ReactionList
model={comment}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
picker={
!comment.isResolved ? (
<StyledReactionPicker
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
size={28}
/>
) : undefined
}
/>
</ReactionListContainer>
)}
</Body>
<EventBoundary>
{!isEditing && (
<Menu
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
onUpdate={handleUpdate}
dir={dir}
/>
<Actions gap={4} dir={dir}>
{!comment.isResolved && (
<StyledReactionPicker
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
/>
)}
<StyledMenu
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
</Actions>
)}
</EventBoundary>
</Bubble>
@@ -257,21 +308,41 @@ const Body = styled.form`
border-radius: 2px;
`;
const Menu = styled(CommentMenu)<{ dir?: "rtl" | "ltr" }>`
const StyledMenu = styled(CommentMenu)`
color: ${s("textSecondary")};
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
}
`;
const StyledReactionPicker = styled(ReactionPicker)`
color: ${s("textSecondary")};
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
}
`;
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;
color: ${s("textSecondary")};
background: ${s("backgroundSecondary")};
padding-left: 4px;
&: ${hover}, &[aria-expanded= "true"] {
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
opacity: 1;
background: ${s("sidebarActiveBackground")};
}
`;
const ReactionListContainer = styled(Flex)`
margin-top: 6px;
`;
const Meta = styled(Text)`
margin-bottom: 2px;
@@ -293,7 +364,7 @@ export const Bubble = styled(Flex)<{
flex-grow: 1;
font-size: 16px;
color: ${s("text")};
background: ${s("commentBackground")};
background: ${s("backgroundSecondary")};
min-width: 2em;
margin-bottom: 1px;
padding: 8px 12px;
@@ -317,7 +388,7 @@ export const Bubble = styled(Flex)<{
margin-bottom: 0;
}
&: ${hover} ${Menu} {
&: ${hover} ${Actions} {
opacity: 1;
}
@@ -12,6 +12,7 @@ import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
@@ -32,6 +33,8 @@ function Comments() {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
// We need to control scroll behaviour when reaction picker is opened / closed.
const [scrollable, enableScroll, disableScroll] = useBoolean(true);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
@@ -131,6 +134,8 @@ function Comments() {
bottomShadow={!focusedComment}
hiddenScrollbars
topShadow
overflow={scrollable ? "auto" : "hidden"}
style={{ overflowX: "hidden" }}
ref={scrollableRef}
onScroll={handleScroll}
>
@@ -143,6 +148,8 @@ function Comments() {
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
enableScroll={enableScroll}
disableScroll={disableScroll}
/>
))
) : (
@@ -150,6 +150,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
documentId: props.id,
createdAt: new Date(),
createdById,
reactions: [],
},
comments
);
@@ -81,7 +81,7 @@ const RecentSearch = styled(Link)`
&:focus,
&:${hover} {
color: ${s("text")};
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
${RemoveButton} {
opacity: 1;
@@ -138,7 +138,7 @@ const DropzoneContainer = styled.div<{
}>`
background: ${(props) =>
props.$isDragActive
? props.theme.secondaryBackground
? props.theme.backgroundSecondary
: props.theme.background};
border-radius: 8px;
border: 1px dashed ${s("divider")};
@@ -149,7 +149,7 @@ const DropzoneContainer = styled.div<{
opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
&:hover {
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
}
`;
@@ -47,7 +47,7 @@ const ImageBox = styled(Flex)`
position: relative;
font-size: 14px;
border-radius: 8px;
box-shadow: 0 0 0 1px ${s("secondaryBackground")};
box-shadow: 0 0 0 1px ${s("backgroundSecondary")};
background: ${s("background")};
overflow: hidden;
+9 -1
View File
@@ -12,6 +12,7 @@ import Document from "./models/Document";
import FileOperation from "./models/FileOperation";
import Pin from "./models/Pin";
import Star from "./models/Star";
import User from "./models/User";
import UserMembership from "./models/UserMembership";
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
@@ -188,6 +189,12 @@ export type WebsocketCollectionUpdateIndexEvent = {
index: string;
};
export type WebsocketCommentReactionEvent = {
emoji: string;
commentId: string;
user: User;
};
export type WebsocketEvent =
| PartialExcept<Pin, "id">
| PartialExcept<Star, "id">
@@ -195,7 +202,8 @@ export type WebsocketEvent =
| PartialExcept<UserMembership, "id">
| WebsocketCollectionUpdateIndexEvent
| WebsocketEntityDeletedEvent
| WebsocketEntitiesEvent;
| WebsocketEntitiesEvent
| WebsocketCommentReactionEvent;
export type AwarenessChangeEvent = {
states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
+3 -2
View File
@@ -120,10 +120,12 @@ declare module "styled-components" {
Breakpoints,
EditorTheme {
background: string;
backgroundSecondary: string;
backgroundTertiary: string;
backgroundQuaternary: string;
backgroundTransition: string;
accent: string;
accentText: string;
secondaryBackground: string;
link: string;
text: string;
cursor: string;
@@ -135,7 +137,6 @@ declare module "styled-components" {
textDiffDeletedBackground: string;
placeholder: string;
commentMarkBackground: string;
commentBackground: string;
sidebarBackground: string;
sidebarActiveBackground: string;
sidebarControlHoverBackground: string;
+1 -1
View File
@@ -213,6 +213,7 @@
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.1",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.6.2",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
@@ -220,7 +221,6 @@
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
"scroll-into-view-if-needed": "^3.1.0",
"socket.io": "^4.7.5",
"socket.io-client": "^4.8.0",
"socket.io-redis": "^6.1.1",
@@ -178,6 +178,10 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "comments.delete":
await this.handleCommentEvent(subscription, event);
return;
case "comments.add_reaction":
case "comments.remove_reaction":
// Ignored
return;
case "groups.create":
case "groups.update":
case "groups.delete":
+1 -1
View File
@@ -8,7 +8,7 @@ type Props = {
export default ({ children, ...rest }: Props) => {
const style = {
border: `1.5px solid ${theme.secondaryBackground}`,
border: `1.5px solid ${theme.backgroundSecondary}`,
borderRadius: "4px",
padding: ".75em 1em",
color: theme.text,
@@ -0,0 +1,74 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.createTable(
"reactions",
{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
emoji: {
type: Sequelize.STRING,
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "users",
},
},
commentId: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "comments",
},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
},
{ transaction }
);
await queryInterface.addIndex("reactions", ["emoji", "userId"], {
transaction,
});
await queryInterface.addIndex("reactions", ["commentId"], {
transaction,
});
await queryInterface.addColumn(
"comments",
"reactions",
{
type: Sequelize.JSONB,
allowNull: true,
},
{ transaction }
);
});
},
async down(queryInterface, Sequelize) {
queryInterface.sequelize.transaction(async transaction => {
await queryInterface.dropTable("reactions", { transaction });
await queryInterface.removeColumn("comments", "reactions", {
transaction,
});
});
},
};
+4 -1
View File
@@ -9,7 +9,7 @@ import {
Length,
DefaultScope,
} from "sequelize-typescript";
import type { ProsemirrorData } from "@shared/types";
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CommentValidation } from "@shared/validations";
import { schema } from "@server/editor";
@@ -51,6 +51,9 @@ class Comment extends ParanoidModel<
@Column(DataType.JSONB)
data: ProsemirrorData;
@Column(DataType.JSONB)
reactions: ReactionSummary[] | null;
// associations
@BelongsTo(() => User, "createdById")
+128
View File
@@ -0,0 +1,128 @@
import uniq from "lodash/uniq";
import {
InferAttributes,
InferCreationAttributes,
type SaveOptions,
} from "sequelize";
import {
AfterCreate,
AfterDestroy,
BelongsTo,
Column,
DataType,
ForeignKey,
Table,
} from "sequelize-typescript";
import Comment from "./Comment";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
@Table({ tableName: "reactions", modelName: "reaction" })
@Fix
class Reaction extends IdModel<
InferAttributes<Reaction>,
Partial<InferCreationAttributes<Reaction>>
> {
@Length({
max: 50,
msg: `emoji must be 50 characters or less`,
})
@Column(DataType.STRING)
emoji: string;
// associations
@BelongsTo(() => User)
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Comment)
comment: Comment;
@ForeignKey(() => Comment)
@Column(DataType.UUID)
commentId: string;
@AfterCreate
public static async addReactionToCommentCache(
model: Reaction,
options: SaveOptions<Reaction>
) {
const { transaction } = options;
const lock = transaction
? {
level: transaction.LOCK.UPDATE,
of: Comment,
}
: undefined;
const comment = await Comment.findByPk(model.commentId, {
transaction,
lock,
});
if (!comment) {
return;
}
const reactions = comment.reactions ?? [];
const reaction = reactions.find((r) => r.emoji === model.emoji);
if (!reaction) {
reactions.push({ emoji: model.emoji, userIds: [model.userId] });
} else {
reaction.userIds = uniq([...reaction.userIds, model.userId]);
}
comment.reactions = reactions;
comment.changed("reactions", true);
await comment.save({ fields: ["reactions"], transaction, silent: true });
}
@AfterDestroy
public static async removeReactionFromCommentCache(
model: Reaction,
options: SaveOptions<Reaction>
) {
const { transaction } = options;
const lock = transaction
? {
level: transaction.LOCK.UPDATE,
of: Comment,
}
: undefined;
const comment = await Comment.findByPk(model.commentId, {
transaction,
lock,
});
if (!comment) {
return;
}
let reactions = comment.reactions ?? [];
const reaction = reactions.find((r) => r.emoji === model.emoji);
if (reaction) {
reaction.userIds = reaction.userIds.filter((id) => id !== model.userId);
if (reaction.userIds.length === 0) {
reactions = reactions.filter((r) => r.emoji !== model.emoji);
}
}
comment.reactions = reactions;
comment.changed("reactions", true);
await comment.save({ fields: ["reactions"], transaction, silent: true });
}
}
export default Reaction;
+2
View File
@@ -32,6 +32,8 @@ export { default as Notification } from "./Notification";
export { default as Pin } from "./Pin";
export { default as Reaction } from "./Reaction";
export { default as Revision } from "./Revision";
export { default as SearchQuery } from "./SearchQuery";
+7
View File
@@ -30,3 +30,10 @@ allow(User, ["update", "delete"], Comment, (actor, comment) =>
or(actor.isAdmin, actor?.id === comment?.createdById)
)
);
allow(
User,
["readReaction", "addReaction", "removeReaction"],
Comment,
(actor, comment) => isTeamModel(actor, comment?.createdBy)
);
+1
View File
@@ -11,6 +11,7 @@ import "./document";
import "./fileOperation";
import "./integration";
import "./pins";
import "./reaction";
import "./searchQuery";
import "./share";
import "./star";
+5
View File
@@ -0,0 +1,5 @@
import { User, Reaction } from "@server/models";
import { allow } from "./cancan";
import { isOwner } from "./utils";
allow(User, "delete", Reaction, isOwner);
+1
View File
@@ -14,5 +14,6 @@ export default function present(comment: Comment) {
resolvedById: comment.resolvedById,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
reactions: comment.reactions ?? [],
};
}
+2
View File
@@ -16,6 +16,7 @@ import presentPin from "./pin";
import presentPolicies from "./policy";
import presentProviderConfig from "./providerConfig";
import presentPublicTeam from "./publicTeam";
import presentReaction from "./reaction";
import presentRevision from "./revision";
import presentSearchQuery from "./searchQuery";
import presentShare from "./share";
@@ -44,6 +45,7 @@ export {
presentPin,
presentPolicies,
presentProviderConfig,
presentReaction,
presentRevision,
presentSearchQuery,
presentShare,
+14
View File
@@ -0,0 +1,14 @@
import { Reaction } from "@server/models";
import presentUser from "./user";
export default function present(reaction: Reaction) {
return {
id: reaction.id,
emoji: reaction.emoji,
commentId: reaction.commentId,
user: presentUser(reaction.user),
userId: reaction.userId,
createdAt: reaction.createdAt,
updatedAt: reaction.updatedAt,
};
}
@@ -505,6 +505,37 @@ export default class WebsocketsProcessor {
});
}
case "comments.add_reaction":
case "comments.remove_reaction": {
const comment = await Comment.findByPk(event.modelId, {
include: [
{
model: Document.scope(["withoutState", "withDrafts"]),
as: "document",
required: true,
},
],
});
if (!comment) {
return;
}
const user = await User.findByPk(event.actorId);
if (!user) {
return;
}
const channels = await this.getDocumentEventChannels(
event,
comment.document
);
return socketio.to(channels).emit(event.name, {
emoji: event.data.emoji,
commentId: event.modelId,
user: presentUser(user),
});
}
case "notifications.create":
case "notifications.update": {
const notification = await Notification.findByPk(event.modelId);
@@ -1,5 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#comments.add_reaction should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#comments.create should require authentication 1`] = `
{
"error": "authentication_required",
@@ -27,6 +36,15 @@ exports[`#comments.list should require authentication 1`] = `
}
`;
exports[`#comments.remove_reaction should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#comments.resolve should require authentication 1`] = `
{
"error": "authentication_required",
+204 -1
View File
@@ -1,4 +1,5 @@
import { CommentStatusFilter } from "@shared/types";
import { CommentStatusFilter, ReactionSummary } from "@shared/types";
import { Comment, Reaction } from "@server/models";
import {
buildAdmin,
buildCollection,
@@ -263,6 +264,40 @@ describe("#comments.list", () => {
expect(body.policies[1].abilities.read).toBeTruthy();
expect(body.pagination.total).toEqual(2);
});
it("should return reactions for a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const reactions: ReactionSummary[] = [
{ emoji: "😄", userIds: [user.id] },
{ emoji: "🙃", userIds: [user.id] },
];
const comment = await buildComment({
userId: user.id,
documentId: document.id,
reactions,
});
const res = await server.post("/api/comments.list", {
body: {
token: user.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(comment.id);
expect(body.data[0].reactions).toEqual(reactions);
expect(body.policies.length).toEqual(1);
expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.pagination.total).toEqual(1);
});
});
describe("#comments.create", () => {
@@ -605,3 +640,171 @@ describe("#comments.unresolve", () => {
expect(body.policies[0].abilities.unresolve).toEqual(false);
});
});
describe("#comments.add_reaction", () => {
it("should require authentication", async () => {
const res = await server.post("/api/comments.add_reaction");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should add a reaction to a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: "😄",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const addedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: "😄", userId: user.id },
});
expect(updatedComment?.reactions).toEqual([
{ emoji: "😄", userIds: [user.id] },
]);
expect(addedReaction).toBeTruthy();
});
it("should add a reaction to a comment with existing reactions", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
reactions: [{ emoji: "😄", userIds: ["test-user"] }],
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: "😄",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const addedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: "😄", userId: user.id },
});
expect(updatedComment?.reactions).toEqual([
{ emoji: "😄", userIds: ["test-user", user.id] },
]);
expect(addedReaction).toBeTruthy();
});
});
describe("#comments.remove_reaction", () => {
it("should require authentication", async () => {
const res = await server.post("/api/comments.remove_reaction");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should remove a reaction from a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
await Reaction.create({
emoji: "😄",
commentId: comment.id,
userId: user.id,
});
const res = await server.post("/api/comments.remove_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: "😄",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const removedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: "😄", userId: user.id },
});
expect(updatedComment?.reactions).toEqual([]);
expect(removedReaction).toBeNull();
});
it("should remove a reaction from a comment with existing reactions", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
reactions: [{ emoji: "😄", userIds: ["test-user"] }],
});
await Reaction.create({
emoji: "😄",
commentId: comment.id,
userId: user.id,
});
const res = await server.post("/api/comments.remove_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: "😄",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const removedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: "😄", userId: user.id },
});
expect(updatedComment?.reactions).toEqual([
{ emoji: "😄", userIds: ["test-user"] },
]);
expect(removedReaction).toBeNull();
});
});
+114 -1
View File
@@ -9,7 +9,7 @@ import { feature } from "@server/middlewares/feature";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, Comment, Collection, Event } from "@server/models";
import { Document, Comment, Collection, Event, Reaction } from "@server/models";
import { authorize } from "@server/policies";
import { presentComment, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
@@ -352,4 +352,117 @@ router.post(
}
);
router.post(
"comments.add_reaction",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
feature(TeamPreference.Commenting),
validate(T.CommentsReactionSchema),
transaction(),
async (ctx: APIContext<T.CommentsReactionReq>) => {
const { id, emoji } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const comment = await Comment.findByPk(id, {
transaction,
rejectOnEmpty: true,
lock: {
level: transaction.LOCK.UPDATE,
of: Comment,
},
});
const document = await Document.findByPk(comment.documentId, {
userId: user.id,
transaction,
});
authorize(user, "comment", document);
authorize(user, "addReaction", comment);
const [, created] = await Reaction.findOrCreate({
where: {
emoji,
userId: user.id,
commentId: id,
},
transaction,
});
if (created) {
await Event.createFromContext(
ctx,
{
name: "comments.add_reaction",
modelId: comment.id,
documentId: comment.documentId,
data: {
emoji,
},
},
{ transaction }
);
}
ctx.body = {
success: true,
};
}
);
router.post(
"comments.remove_reaction",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
feature(TeamPreference.Commenting),
validate(T.CommentsReactionSchema),
transaction(),
async (ctx: APIContext<T.CommentsReactionReq>) => {
const { id, emoji } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const comment = await Comment.findByPk(id, {
transaction,
rejectOnEmpty: true,
lock: {
level: transaction.LOCK.UPDATE,
of: Comment,
},
});
const document = await Document.findByPk(comment.documentId, {
userId: user.id,
transaction,
});
authorize(user, "comment", document);
authorize(user, "removeReaction", comment);
const reaction = await Reaction.findOne({
where: { emoji, userId: user.id, commentId: id },
transaction,
});
authorize(user, "delete", reaction);
await reaction.destroy({ transaction });
await Event.createFromContext(
ctx,
{
name: "comments.remove_reaction",
modelId: comment.id,
documentId: comment.documentId,
data: {
emoji,
},
},
{ transaction }
);
ctx.body = {
success: true,
};
}
);
export default router;
+10
View File
@@ -1,3 +1,4 @@
import emojiRegex from "emoji-regex";
import { z } from "zod";
import { CommentStatusFilter } from "@shared/types";
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
@@ -86,3 +87,12 @@ export const CommentsUnresolveSchema = z.object({
});
export type CommentsUnresolveReq = z.infer<typeof CommentsUnresolveSchema>;
export const CommentsReactionSchema = z.object({
body: BaseIdSchema.extend({
/** Emoji that's added to (or) removed from a comment as a reaction. */
emoji: z.string().regex(emojiRegex()),
}),
});
export type CommentsReactionReq = z.infer<typeof CommentsReactionSchema>;
+2
View File
@@ -27,6 +27,7 @@ import apiTracer from "./middlewares/apiTracer";
import editor from "./middlewares/editor";
import notifications from "./notifications";
import pins from "./pins";
import reactions from "./reactions";
import revisions from "./revisions";
import searches from "./searches";
import shares from "./shares";
@@ -91,6 +92,7 @@ router.use("/", groupMemberships.routes());
router.use("/", fileOperationsRoute.routes());
router.use("/", urls.routes());
router.use("/", userMemberships.routes());
router.use("/", reactions.routes());
if (!env.isCloudHosted) {
router.use("/", installation.routes());
@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#reactions.list should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
+1
View File
@@ -0,0 +1 @@
export { default } from "./reactions";
@@ -0,0 +1,51 @@
import { Reaction } from "@server/models";
import {
buildComment,
buildDocument,
buildTeam,
buildUser,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#reactions.list", () => {
it("should require authentication", async () => {
const res = await server.post("/api/reactions.list");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should return all reactions for a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
await Reaction.bulkCreate([
{ emoji: "😄", commentId: comment.id, userId: user.id },
{ emoji: "😅", commentId: comment.id, userId: user.id },
]);
const res = await server.post("/api/reactions.list", {
body: {
token: user.getJwtToken(),
commentId: comment.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(body.data[0].commentId).toEqual(comment.id);
expect(body.data[0].user.id).toEqual(user.id);
expect(body.data[0].user.name).toEqual(user.name);
expect(body.pagination.total).toEqual(2);
});
});
+65
View File
@@ -0,0 +1,65 @@
import Router from "koa-router";
import { WhereOptions } from "sequelize";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Comment, Document, Reaction, User } from "@server/models";
import { authorize } from "@server/policies";
import { presentReaction } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"reactions.list",
auth(),
pagination(),
validate(T.ReactionsListSchema),
async (ctx: APIContext<T.ReactionsListReq>) => {
const { commentId } = ctx.input.body;
const { user } = ctx.state.auth;
const comment = await Comment.findByPk(commentId, {
rejectOnEmpty: true,
});
const document = await Document.findByPk(comment.documentId, {
userId: user.id,
});
authorize(user, "readReaction", comment);
authorize(user, "read", document);
const where: WhereOptions<Reaction> = {
commentId,
};
const include = [
{
model: User,
required: true,
},
];
const [reactions, total] = await Promise.all([
Reaction.findAll({
where,
include,
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Reaction.count({
where,
include,
}),
]);
ctx.body = {
pagination: { ...ctx.state.pagination, total },
data: reactions.map(presentReaction),
};
}
);
export default router;
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
import { BaseSchema } from "../schema";
export const ReactionsListSchema = BaseSchema.extend({
body: z.object({
/** Id of the comment to list reactions for. */
commentId: z.string().uuid(),
}),
});
export type ReactionsListReq = z.infer<typeof ReactionsListSchema>;
+3
View File
@@ -14,6 +14,7 @@ import {
IntegrationType,
NotificationEventType,
ProsemirrorData,
ReactionSummary,
UserRole,
} from "@shared/types";
import { parser, schema } from "@server/editor";
@@ -413,6 +414,7 @@ export async function buildComment(overrides: {
documentId: string;
parentCommentId?: string;
resolvedById?: string;
reactions?: ReactionSummary[];
}) {
const comment = await Comment.create({
resolvedById: overrides.resolvedById,
@@ -434,6 +436,7 @@ export async function buildComment(overrides: {
],
},
createdById: overrides.userId,
reactions: overrides.reactions,
});
return comment;
+11 -1
View File
@@ -379,6 +379,15 @@ export type CommentUpdateEvent = BaseEvent<Comment> & {
};
};
export type CommentReactionEvent = BaseEvent<Comment> & {
name: "comments.add_reaction" | "comments.remove_reaction";
modelId: string;
documentId: string;
data: {
emoji: string;
};
};
export type CommentEvent =
| (BaseEvent<Comment> & {
name: "comments.create";
@@ -393,7 +402,8 @@ export type CommentEvent =
documentId: string;
actorId: string;
collectionId: string;
});
})
| CommentReactionEvent;
export type StarEvent = BaseEvent<Star> & {
name: "stars.create" | "stars.update" | "stars.delete";
+1 -1
View File
@@ -155,7 +155,7 @@ const Bar = styled.div`
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.embedBorder};
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
color: ${s("textSecondary")};
padding: 0 8px;
border-bottom-left-radius: 6px;
+1 -1
View File
@@ -150,7 +150,7 @@ const Error = styled(Flex)`
max-width: 100%;
color: ${s("textTertiary")};
font-size: 14px;
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
border-radius: 4px;
min-width: 33vw;
height: 80px;
+1 -1
View File
@@ -1770,7 +1770,7 @@ table {
&:focus {
cursor: var(--pointer);
color: ${props.theme.text};
background: ${props.theme.secondaryBackground};
background: ${props.theme.backgroundSecondary};
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ const Wrapper = styled.a`
&:active {
cursor: pointer !important;
text-decoration: none !important;
background: ${s("secondaryBackground")};
background: ${s("backgroundSecondary")};
${Children} {
opacity: 1;
@@ -25,6 +25,8 @@
"Mark as resolved": "Mark as resolved",
"Thread resolved": "Thread resolved",
"Mark as unresolved": "Mark as unresolved",
"View reactions": "View reactions",
"Reactions": "Reactions",
"Copy ID": "Copy ID",
"Clear IndexedDB cache": "Clear IndexedDB cache",
"IndexedDB cache cleared": "IndexedDB cache cleared",
@@ -299,6 +301,13 @@
"Mark all as read": "Mark all as read",
"You're all caught up": "You're all caught up",
"Documents": "Documents",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
"Results": "Results",
"No results for {{query}}": "No results for {{query}}",
"Manage": "Manage",
+6 -6
View File
@@ -111,7 +111,9 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
...colors,
isDark: false,
background: colors.white,
secondaryBackground: colors.warmGrey,
backgroundSecondary: colors.warmGrey,
backgroundTertiary: "#d7e0ea",
backgroundQuaternary: darken(0.05, "#d7e0ea"),
link: colors.accent,
cursor: colors.almostBlack,
text: colors.almostBlack,
@@ -130,8 +132,6 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
backdrop: "rgba(0, 0, 0, 0.2)",
shadow: "rgba(0, 0, 0, 0.2)",
commentBackground: colors.warmGrey,
modalBackdrop: "rgba(0, 0, 0, 0.15)",
modalBackground: colors.white,
modalShadow:
@@ -173,7 +173,9 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
...colors,
isDark: true,
background: colors.almostBlack,
secondaryBackground: colors.black50,
backgroundSecondary: "#1f232e",
backgroundTertiary: "#2a2f3e",
backgroundQuaternary: lighten(0.1, "#2a2f3e"),
link: "#137FFB",
text: colors.almostWhite,
cursor: colors.almostWhite,
@@ -192,8 +194,6 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
backdrop: "rgba(0, 0, 0, 0.5)",
shadow: "rgba(0, 0, 0, 0.6)",
commentBackground: "#1f232e",
modalBackdrop: colors.black50,
modalBackground: "#1f2128",
modalShadow:
+5
View File
@@ -470,3 +470,8 @@ export type EmojiVariants = {
[EmojiSkinTone.MediumDark]?: Emoji;
[EmojiSkinTone.Dark]?: Emoji;
};
export type ReactionSummary = {
emoji: string;
userIds: string[];
};
+9
View File
@@ -137,3 +137,12 @@ export const search = ({
return query === nlc ? -1 : nlc.startsWith(queryLowercase) ? 0 : 1;
});
};
/**
* Get am emoji's human-readable ID from its string.
*
* @param emoji - The string representation of the emoji.
* @returns The emoji id, if found.
*/
export const getEmojiId = (emoji: string): string | undefined =>
searcher.search(emoji)[0]?.id;
+1 -1
View File
@@ -7581,7 +7581,7 @@ ejs@^3.1.6, ejs@^3.1.7:
dependencies:
jake "^10.8.5"
electron-to-chromium@^1.5.28:
electron-to-chromium@^1.5.28, electron-to-chromium@^1.5.4:
version "1.5.32"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.32.tgz#4a05ee78e29e240aabaf73a67ce9fe73f52e1bc7"
integrity sha512-M+7ph0VGBQqqpTT2YrabjNKSQ2fEl9PVx6AK3N558gDH9NO8O6XN9SXXFWRo9u9PbEg/bWq+tjXQr+eXmxubCw==