mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Comment reactions (#7790)
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -226,7 +226,7 @@ const Input = styled.div`
|
||||
}
|
||||
|
||||
&[data-editing="true"] {
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
|
||||
.block-menu-trigger,
|
||||
|
||||
@@ -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`};
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -75,7 +75,7 @@ const Image = styled(Flex)`
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("secondaryBackground")};
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }[];
|
||||
|
||||
Vendored
+3
-2
@@ -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
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import "./document";
|
||||
import "./fileOperation";
|
||||
import "./integration";
|
||||
import "./pins";
|
||||
import "./reaction";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./star";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { User, Reaction } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { isOwner } from "./utils";
|
||||
|
||||
allow(User, "delete", Reaction, isOwner);
|
||||
@@ -14,5 +14,6 @@ export default function present(comment: Comment) {
|
||||
resolvedById: comment.resolvedById,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
reactions: comment.reactions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1770,7 +1770,7 @@ table {
|
||||
&:focus {
|
||||
cursor: var(--pointer);
|
||||
color: ${props.theme.text};
|
||||
background: ${props.theme.secondaryBackground};
|
||||
background: ${props.theme.backgroundSecondary};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -470,3 +470,8 @@ export type EmojiVariants = {
|
||||
[EmojiSkinTone.MediumDark]?: Emoji;
|
||||
[EmojiSkinTone.Dark]?: Emoji;
|
||||
};
|
||||
|
||||
export type ReactionSummary = {
|
||||
emoji: string;
|
||||
userIds: string[];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user