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;