From d3eef27fb70dc5f6aec9719042b319c01fbadcbf Mon Sep 17 00:00:00 2001 From: Komediruzecki Date: Sun, 13 Feb 2022 12:58:19 +0100 Subject: [PATCH] Add initial emoji reaction in comments Update comment reactions Update user showcase issue Add joined reaction counts Count reactions on go Update handling of reactions from another user Fix remove reaction to add if not from current user Update comment list and thread item Add reaction counts and update styles Add comment delete cleanup Add support for dynamic reaction count showcase Add with delayed tooltip commponent Refactor Comment reactions common code to a separate component Add CommentEmoji component --- src/cloud/api/comments/comment.ts | 34 +++ src/cloud/components/CommentEmoji.tsx | 58 ++++++ src/cloud/components/Comments/CommentList.tsx | 131 +++++++++--- .../components/Comments/CommentManager.tsx | 73 +++---- .../components/Comments/CommentReactions.tsx | 167 +++++++++++++++ .../components/Comments/EmojiPickHandler.tsx | 68 ++++++ src/cloud/components/Comments/ThreadItem.tsx | 196 +++++++++++++----- src/cloud/components/Comments/ThreadList.tsx | 10 + src/cloud/interfaces/db/commentReaction.ts | 8 + src/cloud/interfaces/db/comments.ts | 2 + src/cloud/lib/hooks/useCommentManagerState.ts | 2 + src/cloud/lib/stores/comments/index.ts | 16 ++ .../components/atoms/WithDelayedTooltip.tsx | 146 +++++++++++++ src/design/components/atoms/WithTooltip.tsx | 5 +- 14 files changed, 796 insertions(+), 120 deletions(-) create mode 100644 src/cloud/components/CommentEmoji.tsx create mode 100644 src/cloud/components/Comments/CommentReactions.tsx create mode 100644 src/cloud/components/Comments/EmojiPickHandler.tsx create mode 100644 src/cloud/interfaces/db/commentReaction.ts create mode 100644 src/design/components/atoms/WithDelayedTooltip.tsx diff --git a/src/cloud/api/comments/comment.ts b/src/cloud/api/comments/comment.ts index 2fc3bcf449..f9acec7d40 100644 --- a/src/cloud/api/comments/comment.ts +++ b/src/cloud/api/comments/comment.ts @@ -64,6 +64,40 @@ export async function deleteComment({ id }: { id: string }) { await callApi(`api/comments/${id}`, { method: 'delete' }) } +export async function removeReactionFromComment({ + commentId, + reactionId, +}: { + commentId: string + reactionId: string +}): Promise { + const { comment } = await callApi( + `api/comments/${commentId}/reaction`, + { + method: 'delete', + json: { reactionId: reactionId }, + } + ) + return deserialize(comment) +} + +export async function addReaction({ + id, + reaction, +}: { + id: string + reaction: string +}): Promise { + const { comment } = await callApi( + `api/comments/${id}/reaction`, + { + method: 'post', + json: { reaction: reaction }, + } + ) + return deserialize(comment) +} + function deserialize(comment: SerializedComment): Comment { return { ...comment, diff --git a/src/cloud/components/CommentEmoji.tsx b/src/cloud/components/CommentEmoji.tsx new file mode 100644 index 0000000000..402441bffa --- /dev/null +++ b/src/cloud/components/CommentEmoji.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { Emoji } from 'emoji-mart' +import cc from 'classcat' +import Flexbox from '../../design/components/atoms/Flexbox' +import { IconSize } from '../../design/components/atoms/Icon' +import WithDelayedTooltip from '../../design/components/atoms/WithDelayedTooltip' + +interface EmojiIconProps { + emoji: string + defaultIcon?: string + className?: string + style?: React.CSSProperties + onClick?: (event: React.MouseEvent) => void + size?: IconSize + tooltip?: React.ReactNode + emojiTextContent?: React.ReactNode + tooltipDelay: number + tooltipSide?: 'right' | 'bottom' | 'bottom-right' | 'top' +} + +const CommentEmoji = ({ + emoji, + defaultIcon, + className, + style, + size = 34, + tooltip, + tooltipDelay = 0, + onClick, + emojiTextContent, + tooltipSide, +}: EmojiIconProps) => { + if (emoji == null && defaultIcon == null) { + return null + } + + return ( + + + + + {emojiTextContent != null && emojiTextContent} + + + + ) +} + +export default CommentEmoji diff --git a/src/cloud/components/Comments/CommentList.tsx b/src/cloud/components/Comments/CommentList.tsx index c789738e31..261ae27d52 100644 --- a/src/cloud/components/Comments/CommentList.tsx +++ b/src/cloud/components/Comments/CommentList.tsx @@ -4,18 +4,27 @@ import styled from '../../../design/lib/styled' import UserIcon from '../UserIcon' import { format } from 'date-fns' import Icon from '../../../design/components/atoms/Icon' -import { mdiClose, mdiPencil, mdiTrashCanOutline } from '@mdi/js' +import { + mdiClose, + mdiEmoticonHappyOutline, + mdiPencil, + mdiTrashCanOutline, +} from '@mdi/js' import { SerializedUser } from '../../interfaces/db/user' import CommentInput from './CommentInput' import sortBy from 'ramda/es/sortBy' import prop from 'ramda/es/prop' import { toText } from '../../lib/comments' +import CommentReactions from './CommentReactions' +import EmojiPickHandler from './EmojiPickHandler' interface CommentThreadProps { comments: Comment[] className: string updateComment: (comment: Comment, message: string) => Promise - deleteComment: (comment: Comment) => void + deleteComment: (comment: Comment) => Promise + addReaction: (comment: Comment, emoji: string) => Promise + removeReaction: (comment: Comment, reactionId: string) => Promise user?: SerializedUser users: SerializedUser[] } @@ -25,6 +34,8 @@ function CommentList({ className, updateComment, deleteComment, + addReaction, + removeReaction, user, users, }: CommentThreadProps) { @@ -42,6 +53,9 @@ function CommentList({ deleteComment={deleteComment} editable={user != null && comment.user.id === user.id} users={users} + addReaction={addReaction} + removeReaction={removeReaction} + user={user} /> ))} @@ -52,19 +66,32 @@ function CommentList({ interface CommentItemProps { comment: Comment updateComment: (comment: Comment, message: string) => Promise - deleteComment: (comment: Comment) => void + deleteComment: (comment: Comment) => Promise + addReaction: (comment: Comment, emoji: string) => Promise + removeReaction: (comment: Comment, reactionId: string) => Promise editable?: boolean users: SerializedUser[] + user?: SerializedUser } const smallUserIconStyle = { width: '28px', height: '28px', lineHeight: '26px' } +export interface EmojiReactionData { + id: string + emoji: string + count: number + userIds: string[] +} + export function CommentItem({ comment, editable, updateComment, deleteComment, + addReaction, + removeReaction, users, + user, }: CommentItemProps) { const [editing, setEditing] = useState(false) const [showingContextMenu, setShowingContextMenu] = useState(false) @@ -81,6 +108,50 @@ export function CommentItem({ return toText(comment.message, users) }, [comment.message, users]) + const contextMenuItems = useCallback(() => { + if (editable) { + return ( +
+ + + +
setEditing(true)} + className='comment__meta__actions__edit' + > + +
+
deleteComment(comment)} + className='comment__meta__actions__remove' + > + +
+
+ ) + } else { + return ( +
+ + + +
+ ) + } + }, [addReaction, comment, deleteComment, editable, removeReaction, user]) + return (
@@ -98,29 +169,14 @@ export function CommentItem({ {format(comment.createdAt, 'hh:mmaaa MMM do')} - {editable && - (editing ? ( -
setEditing(false)}> - -
- ) : ( - showingContextMenu && ( -
-
setEditing(true)} - className='comment__meta__actions__edit' - > - -
-
deleteComment(comment)} - className='comment__meta__actions__remove' - > - -
-
- ) - ))} + + {editing ? ( +
setEditing(false)}> + +
+ ) : ( + showingContextMenu && contextMenuItems() + )}
{editing ? ( ) : ( -
{content}
+ <> +
{content}
+ + )}
@@ -183,6 +248,13 @@ const CommentItemContainer = styled.div` } } + .comment__add__reaction__button { + color: #9e9e9e; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + border-radius: 6px; + padding: 4px 8px; + } + .comment__message { white-space: pre-wrap; word-break: break-word; @@ -200,10 +272,11 @@ const CommentItemContainer = styled.div` gap: 4px; border-radius: ${({ theme }) => theme.borders.radius}px; - background-color: #1e2024; + background-color: ${({ theme }) => theme.colors.background.primary}; .comment__meta__actions__edit, - .comment__meta__actions__remove { + .comment__meta__actions__remove, + .comment__meta__actions__emoji { height: 20px; margin: 3px; diff --git a/src/cloud/components/Comments/CommentManager.tsx b/src/cloud/components/Comments/CommentManager.tsx index 13f39a206d..8eced8632d 100644 --- a/src/cloud/components/Comments/CommentManager.tsx +++ b/src/cloud/components/Comments/CommentManager.tsx @@ -13,6 +13,7 @@ import { useI18n } from '../../lib/hooks/useI18n' import { lngKeys } from '../../lib/i18n/types' import Button from '../../../design/components/atoms/Button' import { DialogIconTypes, useDialog } from '../../../design/lib/stores/dialog' +import { listThreadComments } from '../../api/comments/comment' export type State = | { mode: 'list_loading'; thread?: { id: string } } @@ -44,6 +45,11 @@ export interface Actions { createComment: (thread: Thread, message: string) => Promise updateComment: (comment: Comment, message: string) => Promise deleteComment: (comment: Comment) => Promise + addReaction: (comment: Comment, reaction: string) => Promise + removeReaction: ( + comment: Comment, + reactionId: string + ) => Promise } interface CommentManagerProps extends Actions { @@ -60,6 +66,8 @@ function CommentManager({ createComment, updateComment, deleteComment, + addReaction, + removeReaction, user, users, }: CommentManagerProps) { @@ -69,40 +77,12 @@ function CommentManager({ return users != null ? users : [] }, [users]) - const deleteCommentWithPrompt = useCallback( - (comment: Comment) => { + const deleteCommentWithCleanup = useCallback( + async (comment: Comment, thread: Thread) => { messageBox({ title: translate(lngKeys.ModalsDeleteDocFolderTitle, { label: 'Comment', }), - message: translate(lngKeys.ModalsDeleteCommentDisclaimer), - iconType: DialogIconTypes.Warning, - buttons: [ - { - variant: 'secondary', - label: translate(lngKeys.GeneralCancel), - cancelButton: true, - defaultButton: true, - }, - { - variant: 'danger', - label: translate(lngKeys.GeneralDelete), - onClick: async () => { - await deleteComment(comment) - }, - }, - ], - }) - }, - [deleteComment, messageBox, translate] - ) - - const deleteThreadWithPrompt = useCallback( - (thread: Thread) => { - messageBox({ - title: translate(lngKeys.ModalsDeleteDocFolderTitle, { - label: 'Thread', - }), message: translate(lngKeys.ModalsDeleteThreadDisclaimer), iconType: DialogIconTypes.Warning, buttons: [ @@ -116,13 +96,19 @@ function CommentManager({ variant: 'danger', label: translate(lngKeys.GeneralDelete), onClick: async () => { - await deleteThread(thread) + await deleteComment(comment) + // todo: [komediruzecki-2022-02-21] if thread.commentCount is updated properly we can just check the number of comments there + listThreadComments({ id: thread.id }).then((comments) => { + if (comments.length == 0) { + deleteThread(thread) + } + }) }, }, ], }) }, - [deleteThread, messageBox, translate] + [deleteComment, deleteThread, messageBox, translate] ) const content = useMemo(() => { @@ -141,9 +127,12 @@ function CommentManager({ setMode({ mode: 'thread', thread })} - onDelete={(thread) => deleteThreadWithPrompt(thread)} + onDelete={deleteThread} + user={user} users={usersOrEmpty} updateComment={updateComment} + addReaction={addReaction} + removeReaction={removeReaction} /> deleteCommentWithPrompt(comment)} + deleteComment={(comment) => + deleteCommentWithCleanup(comment, state.thread) + } + addReaction={addReaction} + removeReaction={removeReaction} user={user} users={usersOrEmpty} /> @@ -211,11 +204,13 @@ function CommentManager({ createThread, createComment, updateComment, - deleteCommentWithPrompt, - deleteThreadWithPrompt, setMode, + deleteThread, user, usersOrEmpty, + addReaction, + removeReaction, + deleteCommentWithCleanup, ]) return ( @@ -306,7 +301,7 @@ const Container = styled.div` .thread__create { display: flex; align-items: center; - padding: 0px ${({ theme }) => theme.sizes.spaces.df}px; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; margin: ${({ theme }) => theme.sizes.spaces.df}px 0; cursor: default; color: ${({ theme }) => theme.colors.text.secondary}; @@ -319,7 +314,7 @@ const Container = styled.div` } .thread__new { - padding: 0px ${({ theme }) => theme.sizes.spaces.df}px; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; } .thread__loading { @@ -335,7 +330,7 @@ const Container = styled.div` flex: 1; height: 100%; overflow-y: auto; - padding 0 ${({ theme }) => theme.sizes.spaces.df}px;; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; } ` diff --git a/src/cloud/components/Comments/CommentReactions.tsx b/src/cloud/components/Comments/CommentReactions.tsx new file mode 100644 index 0000000000..982e2479c2 --- /dev/null +++ b/src/cloud/components/Comments/CommentReactions.tsx @@ -0,0 +1,167 @@ +import { Comment } from '../../interfaces/db/comments' +import { SerializedUser } from '../../interfaces/db/user' +import CommentEmoji from '../CommentEmoji' +import React, { useCallback, useMemo } from 'react' +import Button from '../../../design/components/atoms/Button' +import { mdiPlus } from '@mdi/js' +import { EmojiReactionData } from './CommentList' +import styled from '../../../design/lib/styled' +import EmojiPickHandler from './EmojiPickHandler' +import { CommentReaction } from '../../interfaces/db/commentReaction' +import { Emoji } from 'emoji-mart' + +export type CommentReactionsProps = { + comment: Comment + users: SerializedUser[] + addReaction: (comment: Comment, emoji: string) => Promise + removeReaction: (comment: Comment, reactionId: string) => Promise + user?: SerializedUser +} + +const CommentReactions = ({ + comment, + addReaction, + removeReaction, + users, + user, +}: CommentReactionsProps) => { + const filteredReactions = useMemo(() => { + const emojiReduce = (reactions: CommentReaction[], key: string) => + Object.values( + reactions.reduce((acc, reaction) => { + const value = reaction[key] + + if (!acc[value]) { + acc[value] = { + id: reaction.id, + emoji: value, + count: 0, + userIds: [reaction.teamMember.userId], + } as EmojiReactionData + } else { + acc[value].userIds.push(reaction.teamMember.userId) + } + acc[value].count++ + return acc + }, {}) + ) + const emojiMap = emojiReduce(comment.reactions, 'emoji') + return emojiMap as EmojiReactionData[] + }, [comment.reactions]) + + const removeOrAddReaction = useCallback( + async (comment: Comment, emoji: string) => { + const userReactions = comment.reactions.filter( + (reaction) => + user != null && + reaction.emoji == emoji && + reaction.teamMember.userId == user.id + ) + if (userReactions.length > 0) { + await removeReaction(comment, userReactions[0].id) + } else { + await addReaction(comment, emoji) + } + }, + [addReaction, removeReaction, user] + ) + + const getCommentReactionUserNames = useCallback( + (reaction: EmojiReactionData) => { + const usersData = [] + for (const userMember of users) { + if (reaction.userIds.indexOf(userMember.id) > -1) { + usersData.push(userMember.displayName) + } + } + + const commentReactionString = ` reacted with :${reaction.emoji}:` + return ( + +
+ +
+ {usersData.join(', ')} + + {commentReactionString} + +
+ ) + }, + [users] + ) + + return ( + + {filteredReactions.map((reaction) => ( + removeOrAddReaction(comment, reaction.emoji)} + emojiTextContent={ +
+ {reaction.count > 999 ? '999+' : `${reaction.count}`} +
+ } + /> + ))} + {filteredReactions.length > 0 && ( + +