diff --git a/app/actions/definitions/comments.tsx b/app/actions/definitions/comments.tsx
index 0ed6206eb0..2f430dcd29 100644
--- a/app/actions/definitions/comments.tsx
+++ b/app/actions/definitions/comments.tsx
@@ -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: ,
+ 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: ,
+ });
+ },
+ });
diff --git a/app/components/CollectionDescription.tsx b/app/components/CollectionDescription.tsx
index fcfe25d27d..b891bc3de3 100644
--- a/app/components/CollectionDescription.tsx
+++ b/app/components/CollectionDescription.tsx
@@ -226,7 +226,7 @@ const Input = styled.div`
}
&[data-editing="true"] {
- background: ${s("secondaryBackground")};
+ background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
diff --git a/app/components/Emoji.tsx b/app/components/Emoji.tsx
new file mode 100644
index 0000000000..5c5441e9dc
--- /dev/null
+++ b/app/components/Emoji.tsx
@@ -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`
+ 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`};
+`;
diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx
index e2a0dcd1c9..207da6a698 100644
--- a/app/components/ErrorBoundary.tsx
+++ b/app/components/ErrorBoundary.tsx
@@ -138,7 +138,7 @@ class ErrorBoundary extends React.Component {
}
const Pre = styled.pre`
- background: ${s("secondaryBackground")};
+ background: ${s("backgroundSecondary")};
padding: 16px;
border-radius: 4px;
font-size: 12px;
diff --git a/app/components/GroupListItem.tsx b/app/components/GroupListItem.tsx
index ac18bc1b6f..8081525808 100644
--- a/app/components/GroupListItem.tsx
+++ b/app/components/GroupListItem.tsx
@@ -75,7 +75,7 @@ const Image = styled(Flex)`
justify-content: center;
width: 32px;
height: 32px;
- background: ${s("secondaryBackground")};
+ background: ${s("backgroundSecondary")};
border-radius: 32px;
`;
diff --git a/app/components/HoverPreview/Components.tsx b/app/components/HoverPreview/Components.tsx
index 8f2b1dffc9..f56cf13625 100644
--- a/app/components/HoverPreview/Components.tsx
+++ b/app/components/HoverPreview/Components.tsx
@@ -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;
diff --git a/app/components/IconPicker/components/Emoji.tsx b/app/components/IconPicker/components/Emoji.tsx
deleted file mode 100644
index 2223147282..0000000000
--- a/app/components/IconPicker/components/Emoji.tsx
+++ /dev/null
@@ -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;
-`;
diff --git a/app/components/IconPicker/components/GridTemplate.tsx b/app/components/IconPicker/components/GridTemplate.tsx
index c3abc3589c..832a5e1c70 100644
--- a/app/components/IconPicker/components/GridTemplate.tsx
+++ b/app/components/IconPicker/components/GridTemplate.tsx
@@ -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 })}
>
- {item.value}
+
+ {item.value}
+
);
});
diff --git a/app/components/IconPicker/components/SkinTonePicker.tsx b/app/components/IconPicker/components/SkinTonePicker.tsx
index 2061fd302d..bea78e3451 100644
--- a/app/components/IconPicker/components/SkinTonePicker.tsx
+++ b/app/components/IconPicker/components/SkinTonePicker.tsx
@@ -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 = ({
diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx
index 1ea2d2d042..5dfe31560f 100644
--- a/app/components/List/Item.tsx
+++ b/app/components/List/Item.tsx
@@ -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) =>
diff --git a/app/components/Reactions/Reaction.tsx b/app/components/Reactions/Reaction.tsx
new file mode 100644
index 0000000000..0ed8d0799e
--- /dev/null
+++ b/app/components/Reactions/Reaction.tsx
@@ -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;
+ /** Callback when the user intends to remove the reaction. */
+ onRemoveReaction: (emoji: string) => Promise;
+};
+
+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 = ({
+ 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) => {
+ event.stopPropagation();
+ active
+ ? void onRemoveReaction(reaction.emoji)
+ : void onAddReaction(reaction.emoji);
+ },
+ [reaction, active, onAddReaction, onRemoveReaction]
+ );
+
+ const DisplayedEmoji = React.useMemo(
+ () => (
+
+
+ {reaction.emoji}
+ {reaction.userIds.length}
+
+
+ ),
+ [reaction.emoji, reaction.userIds, disabled, active, handleClick]
+ );
+
+ return tooltipContent ? (
+
+ {DisplayedEmoji}
+
+ ) : (
+ <>{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);
diff --git a/app/components/Reactions/ReactionList.tsx b/app/components/Reactions/ReactionList.tsx
new file mode 100644
index 0000000000..fbe5b94697
--- /dev/null
+++ b/app/components/Reactions/ReactionList.tsx
@@ -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;
+ /** Callback when the user intends to remove a reaction. */
+ onRemoveReaction: (emoji: string) => Promise;
+ /** classname generated by styled-components. */
+ className?: string;
+ /** Picker to render as the last element */
+ picker?: React.ReactElement;
+};
+
+const ReactionList: React.FC = ({
+ model,
+ onAddReaction,
+ onRemoveReaction,
+ className,
+ picker,
+}) => {
+ const { users } = useStores();
+ const listRef = React.useRef(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 (
+
+
+ {model.reactions.map((reaction) => {
+ const reactedUsers = compact(
+ reaction.userIds.map((id) => users.get(id))
+ );
+
+ return (
+
+ );
+ })}
+ {picker}
+
+
+ );
+};
+
+export default observer(ReactionList);
diff --git a/app/components/Reactions/ReactionPicker.tsx b/app/components/Reactions/ReactionPicker.tsx
new file mode 100644
index 0000000000..1ca87e10b2
--- /dev/null
+++ b/app/components/Reactions/ReactionPicker.tsx
@@ -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;
+ /** 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 = ({
+ 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(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 (
+ <>
+
+ {(props) => (
+
+
+
+ )}
+
+ e.stopPropagation()}
+ hideOnClickOutside={false}
+ >
+ {popover.visible && (
+ }>
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+const Placeholder = React.memo(
+ () => (
+
+
+
+
+
+
+
+ ),
+ () => true
+);
+Placeholder.displayName = "ReactionPickerPlaceholder";
+
+const ScrollableContainer = styled.div`
+ height: 300px;
+ overflow-y: auto;
+`;
+
+const PopoverButton = styled(NudeButton)`
+ border-radius: 50%;
+`;
+
+export default ReactionPicker;
diff --git a/app/components/Reactions/ViewReactionsDialog.tsx b/app/components/Reactions/ViewReactionsDialog.tsx
new file mode 100644
index 0000000000..f824d43fb9
--- /dev/null
+++ b/app/components/Reactions/ViewReactionsDialog.tsx
@@ -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 = ({ 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 ;
+ }
+
+ return (
+ <>
+
+ {model.reactions.map((reaction) => (
+
+ {reaction.emoji}
+
+ ))}
+
+ {model.reactions.map((reaction) => {
+ const reactedUsers = compact(
+ reaction.userIds.map((id) => users.get(id))
+ );
+
+ return (
+
+ {reactedUsers.map((user) => (
+
+
+ {user.name}
+
+ ))}
+
+ );
+ })}
+ >
+ );
+};
+
+const PlaceHolder = React.memo(
+ () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ () => 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);
diff --git a/app/components/Table.tsx b/app/components/Table.tsx
index e264a73e86..16d3e8e8ca 100644
--- a/app/components/Table.tsx
+++ b/app/components/Table.tsx
@@ -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"};
}
`;
diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx
index 9b19024245..f4d83ef323 100644
--- a/app/components/WebsocketProvider.tsx
+++ b/app/components/WebsocketProvider.tsx
@@ -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 {
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) => {
groups.add(event);
});
diff --git a/app/hooks/useHover.ts b/app/hooks/useHover.ts
new file mode 100644
index 0000000000..434c7120c6
--- /dev/null
+++ b/app/hooks/useHover.ts
@@ -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;
+ /** 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();
+
+ 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;
diff --git a/app/menus/CommentMenu.tsx b/app/menus/CommentMenu.tsx
index 2935601d37..d2205445cf 100644
--- a/app/menus/CommentMenu.tsx
+++ b/app/menus/CommentMenu.tsx
@@ -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}
/>
-
- ,
- 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: ,
- title: t("Copy link"),
- onClick: handleCopyLink,
- },
- {
- type: "separator",
- },
- actionToMenuItem(
- deleteCommentFactory({ comment, onDelete }),
- context
- ),
- ]}
- />
-
+ {menu.visible && (
+
+ ,
+ 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: ,
+ title: t("Copy link"),
+ onClick: handleCopyLink,
+ },
+ {
+ type: "separator",
+ },
+ actionToMenuItem(
+ deleteCommentFactory({ comment, onDelete }),
+ context
+ ),
+ ]}
+ />
+
+ )}
>
);
}
diff --git a/app/models/Comment.ts b/app/models/Comment.ts
index 89461ea097..fa6e9d529f 100644
--- a/app/models/Comment.ts
+++ b/app/models/Comment.ts
@@ -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;
diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx
index de95815070..c161744bb9 100644
--- a/app/scenes/Document/components/CommentForm.tsx
+++ b/app/scenes/Document/components/CommentForm.tsx
@@ -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
);
diff --git a/app/scenes/Document/components/CommentThread.tsx b/app/scenes/Document/components/CommentThread.tsx
index 04865bb181..bde2dc6d6f 100644
--- a/app/scenes/Document/components/CommentThread.tsx
+++ b/app/scenes/Document/components/CommentThread.tsx
@@ -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): [undefined, () => void] {
+}: Pick): [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}
/>
);
})}
diff --git a/app/scenes/Document/components/CommentThreadItem.tsx b/app/scenes/Document/components/CommentThreadItem.tsx
index 5b24906e45..bb053cb453 100644
--- a/app/scenes/Document/components/CommentThreadItem.tsx
+++ b/app/scenes/Document/components/CommentThreadItem.tsx
@@ -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(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({
)}
+ {!!comment.reactions.length && (
+
+
+ ) : undefined
+ }
+ />
+
+ )}