import React, { useCallback, useEffect, useState } from "react"; import Editor from "@draft-js-plugins/editor"; import { CompositeDecorator, convertFromRaw, DraftDecorator, EditorState, RawDraftContentState, } from "draft-js"; import styled from "styled-components"; import ProfileImage, { Profile } from "pages/common/ProfileImage"; import { Comment, Reaction } from "entities/Comments/CommentsInterfaces"; import { getTypographyByKey } from "constants/DefaultTheme"; import CommentContextMenu from "./CommentContextMenu"; import ResolveCommentButton from "comments/CommentCard/ResolveCommentButton"; import { MentionComponent } from "components/ads/MentionsInput"; import Icon, { IconSize } from "components/ads/Icon"; import EmojiReactions, { Reaction as ComponentReaction, Reactions, ReactionOperation, } from "components/ads/EmojiReactions"; import { Toaster } from "components/ads/Toast"; import AddCommentInput from "comments/inlineComments/AddCommentInput"; import createMentionPlugin from "@draft-js-plugins/mention"; import { flattenDeep, noop } from "lodash"; import copy from "copy-to-clipboard"; import moment from "moment"; import history from "utils/history"; import { getAppMode } from "selectors/applicationSelectors"; import { widgetsMapWithParentModalId } from "selectors/entitiesSelector"; import { USER_PHOTO_ASSET_URL } from "constants/userConstants"; import { getCommentThreadURL } from "../utils"; import { deleteCommentRequest, markThreadAsReadRequest, pinCommentThreadRequest, editCommentRequest, deleteCommentThreadRequest, addCommentReaction, removeCommentReaction, } from "actions/commentActions"; import { useDispatch, useSelector } from "react-redux"; import { commentThreadsSelector } from "selectors/commentsSelectors"; import { getCurrentUser } from "selectors/usersSelectors"; import { createMessage, LINK_COPIED_SUCCESSFULLY, } from "@appsmith/constants/messages"; import { Variant } from "components/ads/common"; import TourTooltipWrapper from "components/ads/tour/TourTooltipWrapper"; import { TourType } from "entities/Tour"; import useProceedToNextTourStep from "utils/hooks/useProceedToNextTourStep"; import { commentsTourStepsEditModeTypes } from "comments/tour/commentsTourSteps"; import { useNavigateToWidget } from "pages/Editor/Explorer/Widgets/useNavigateToWidget"; const StyledContainer = styled.div` width: 100%; padding: ${(props) => `${props.theme.spaces[6]}px ${props.theme.spaces[5]}px`}; border-radius: 0; &:hover { background-color: ${(props) => props.theme.colors.comments.cardHoverBackground}; } `; const CommentBodyContainer = styled.div` padding-bottom: ${(props) => props.theme.spaces[4]}px; color: ${(props) => props.theme.colors.comments.profileUserName}; `; const CommentHeader = styled.div` display: flex; justify-content: space-between; align-items: center; padding-bottom: ${(props) => props.theme.spaces[4]}px; `; const UserName = styled.span` ${(props) => getTypographyByKey(props, "h5")} color: ${(props) => props.theme.colors.comments.profileUserName}; margin-left: ${(props) => props.theme.spaces[4]}px; display: flex; align-items: center; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 1; /* number of lines to show */ -webkit-box-orient: vertical; word-break: break-word; `; const HeaderSection = styled.div` display: flex; align-items: center; max-width: 100%; & ${Profile} { flex-shrink: 0; } `; const CommentTime = styled.div` color: ${(props) => props.theme.colors.comments.commentTime}; ${(props) => getTypographyByKey(props, "p3")} display: flex; justify-content: space-between; margin-bottom: ${(props) => props.theme.spaces[4]}px; `; const CommentSubheader = styled.div` display: flex; align-items: center; justify-content: space-between; margin-bottom: ${(props) => props.theme.spaces[4]}px; white-space: nowrap; ${(props) => getTypographyByKey(props, "p3")} color: ${(props) => props.theme.colors.comments.pinnedByText}; & .thread-id { flex-shrink: 0; max-width: 50px; } & .pin { margin: 0 ${(props) => props.theme.spaces[3]}px; } strong { white-space: pre; margin-left: ${(props) => props.theme.spaces[0]}px; text-overflow: ellipsis; overflow: hidden; } `; const CommentThreadId = styled.div` color: ${(props) => props.theme.colors.comments.commentTime}; ${(props) => getTypographyByKey(props, "p3")} overflow: hidden; text-overflow: ellipsis; `; const Section = styled.div` display: flex; align-items: center; overflow: hidden; text-overflow: ellipsis; `; const UnreadIndicator = styled.div` width: 6px; height: 6px; border-radius: 50%; background-color: ${(props) => props.theme.colors.comments.unreadIndicatorCommentCard}; margin-right: ${(props) => props.theme.spaces[2]}px; flex-shrink: 0; `; const ReactionsRow = styled.div` display: flex; `; const EmojiReactionsBtnContainer = styled.div``; const mentionPlugin = createMentionPlugin({ mentionComponent: MentionComponent, }); const plugins = [mentionPlugin]; const decorators = flattenDeep(plugins.map((plugin) => plugin.decorators)); const decorator = new CompositeDecorator( decorators.filter((_decorator, index) => index !== 1) as DraftDecorator[], ); function StopClickPropagation({ children }: { children: React.ReactNode }) { return (
e.stopPropagation()} style={{ display: "flex" }} > {children}
); } const replyText = (replies?: number) => { if (!replies) return ""; return replies > 1 ? `${replies} Replies` : `1 Reply`; }; enum CommentCardModes { EDIT = "EDIT", VIEW = "VIEW", } const reduceReactions = ( reactions: Array | undefined, username?: string, ) => { return ( (Array.isArray(reactions) && reactions.reduce( (res: Record, reaction: Reaction) => { const { byName, byUsername, emoji } = reaction; const sameAsCurrent = byUsername === username; const name = byName || byUsername; if (res[reaction.emoji]) { res[reaction.emoji].count++; if (!sameAsCurrent) { res[reaction.emoji].users = [ ...(res[reaction.emoji].users || []), name, ]; } } else { const users = !sameAsCurrent ? [name] : []; res[emoji] = { count: 1, reactionEmoji: emoji, users, } as ComponentReaction; } if (sameAsCurrent) { res[reaction.emoji].active = true; } return res; }, {}, )) || undefined ); }; const ResolveButtonContainer = styled.div` margin-left: ${(props) => props.theme.spaces[2]}px; `; function CommentCard({ comment, commentThreadId, inline, isParentComment, numberOfReplies, resolved, showReplies, showSubheader, toggleResolved, unread = true, visible, }: { comment: Comment; isEditMode?: boolean; isParentComment?: boolean; resolved?: boolean; toggleResolved?: () => void; commentThreadId: string; numberOfReplies?: number; showReplies?: boolean; showSubheader?: boolean; unread?: boolean; inline?: boolean; visible?: boolean; }) { const proceedToNextTourStep = useProceedToNextTourStep({ [TourType.COMMENTS_TOUR_EDIT_MODE]: commentsTourStepsEditModeTypes.RESOLVE, }); const [isHovered, setIsHovered] = useState(false); const [cardMode, setCardMode] = useState(CommentCardModes.VIEW); const dispatch = useDispatch(); const { authorName, authorPhotoId, body, id: commentId } = comment; const contentState = convertFromRaw(body as RawDraftContentState); const editorState = EditorState.createWithContent(contentState, decorator); const commentThread = useSelector(commentThreadsSelector(commentThreadId)); const [reactions, setReactions] = useState(); const currentUser = useSelector(getCurrentUser); const currentUserUsername = currentUser?.username; const isPinned = commentThread.pinnedState?.active; const pinnedByUsername = commentThread.pinnedState?.authorUsername; let pinnedBy = commentThread.pinnedState?.authorName; const appMode = useSelector(getAppMode); if (currentUserUsername === pinnedByUsername) { pinnedBy = "You"; } const commentThreadURL = getCommentThreadURL({ commentThreadId, isResolved: !!commentThread?.resolvedState?.active, pageId: commentThread?.pageId, mode: appMode, }); const copyCommentLink = () => { copy(commentThreadURL.toString()); Toaster.show({ text: createMessage(LINK_COPIED_SUCCESSFULLY), variant: Variant.success, }); }; const pin = useCallback(() => { dispatch( pinCommentThreadRequest({ threadId: commentThreadId, pin: !isPinned }), ); }, [isPinned]); const deleteComment = useCallback(() => { dispatch(deleteCommentRequest({ threadId: commentThreadId, commentId })); }, []); const deleteThread = () => { dispatch(deleteCommentThreadRequest(commentThreadId)); }; const isCreatedByMe = currentUserUsername === comment.authorUsername; const switchToEditCommentMode = () => setCardMode(CommentCardModes.EDIT); const switchToViewCommentMode = () => setCardMode(CommentCardModes.VIEW); const onSaveComment = (body: RawDraftContentState) => { dispatch(editCommentRequest({ commentId, commentThreadId, body })); setCardMode(CommentCardModes.VIEW); }; const widgetMap: Record = useSelector( widgetsMapWithParentModalId, ); const contextMenuProps = { switchToEditCommentMode, pin, copyCommentLink, deleteComment, deleteThread, isParentComment, isCreatedByMe, isPinned, }; // TODO enable when comments links are enabled // useSelectCommentUsingQuery(comment.id); const { navigateToWidget } = useNavigateToWidget(); // Dont make inline cards clickable // TODO check if type === widget const handleCardClick = () => { if (inline) return; if (commentThread.widgetType) { // for the view mode we use canvas widgets instead of widgets by page // since we don't have the dsl for all the pages currently const widget = widgetMap[commentThread.refId]; // 1. This is only needed for the modal widgetMap // 2. TODO check if we can do something similar for tabs // 3. getAllWidgetsMap doesn't exist for the view mode, so these won't work for the view mode if (widget?.parentModalId) { navigateToWidget( commentThread.refId, commentThread.widgetType, widget.pageId, false, widget.parentModalId, ); } } history.push( `${commentThreadURL.pathname}${commentThreadURL.search}${commentThreadURL.hash}`, ); if (!commentThread?.isViewed) { dispatch(markThreadAsReadRequest(commentThreadId)); } }; useEffect(() => { setReactions(reduceReactions(comment.reactions, currentUserUsername)); }, [comment.reactions]); const handleReaction = ( _event: React.MouseEvent, emojiData: string, updatedReactions: Reactions, addOrRemove: ReactionOperation, ) => { setReactions(updatedReactions); if (addOrRemove == ReactionOperation.ADD) { dispatch(addCommentReaction({ emoji: emojiData, commentId })); } else { dispatch(removeCommentReaction({ emoji: emojiData, commentId })); } }; const showOptions = visible || isHovered; const showResolveBtn = (showOptions || !!resolved) && isParentComment && toggleResolved; const hasReactions = !!reactions && Object.keys(reactions).length > 0; const profilePhotoUrl = authorPhotoId ? `/api/${USER_PHOTO_ASSET_URL}/${authorPhotoId}` : ""; return ( setIsHovered(false)} onMouseOver={() => setIsHovered(true)} > {showSubheader && (
{unread && } {commentThread.sequenceId}
{isPinned && ( <> Pinned By {` ${pinnedBy}`} )}
)} {authorName} {showOptions && ( )} {showResolveBtn && ( {inline ? ( { toggleResolved && toggleResolved(); proceedToNextTourStep(); }} resolved={!!resolved} /> ) : ( void} resolved={!!resolved} /> )} )} {showOptions && ( )} {cardMode === CommentCardModes.EDIT ? ( ) : ( )} {moment(comment.creationTime).fromNow()} {showReplies && replyText(numberOfReplies)} {hasReactions && ( )}
); } export default CommentCard;