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 = ({ {(menuprops) => ( handleSkinClick(eskin)}> - {emoji.value} + + {emoji.value} + )} 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} /> - -