From 8c63a9d7cd7e3c7b906c1315a90f9832981ece06 Mon Sep 17 00:00:00 2001 From: gurusainath Date: Mon, 22 Jan 2024 16:30:24 +0530 Subject: [PATCH] chore: issue comment reaction --- .../activity/issue_comment_reaction.d.ts | 14 +-- packages/types/src/issues/issue_reaction.d.ts | 2 +- .../issue-activity/comments/comment-card.tsx | 13 +- .../issue-detail/issue-activity/root.tsx | 58 +-------- .../issue-detail/reactions/issue-comment.tsx | 118 ++++++++++++++++++ .../issues/issue-detail/reactions/issue.tsx | 2 +- .../issue/issue-details/comment.store.ts | 1 + .../issue-details/comment_reaction.store.ts | 93 +++++++++++--- web/store/issue/issue-details/root.store.ts | 13 +- 9 files changed, 232 insertions(+), 82 deletions(-) create mode 100644 web/components/issues/issue-detail/reactions/issue-comment.tsx diff --git a/packages/types/src/issues/activity/issue_comment_reaction.d.ts b/packages/types/src/issues/activity/issue_comment_reaction.d.ts index 8a3695e85..892a3e906 100644 --- a/packages/types/src/issues/activity/issue_comment_reaction.d.ts +++ b/packages/types/src/issues/activity/issue_comment_reaction.d.ts @@ -1,20 +1,20 @@ export type TIssueCommentReaction = { id: string; + comment: string; + actor: string; + reaction: string; + workspace: string; + project: string; created_at: Date; updated_at: Date; - reaction: string; created_by: string; updated_by: string; - project: string; - workspace: string; - actor: string; - comment: string; }; export type TIssueCommentReactionMap = { - [issue_id: string]: TIssueCommentReaction; + [reaction_id: string]: TIssueCommentReaction; }; export type TIssueCommentReactionIdMap = { - [issue_id: string]: string[]; + [comment_id: string]: { [reaction: string]: string[] }; }; diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts index 6fc071a9f..88ef27426 100644 --- a/packages/types/src/issues/issue_reaction.d.ts +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -13,7 +13,7 @@ export type TIssueReaction = { }; export type TIssueReactionMap = { - [issue_id: string]: TIssueReaction; + [reaction_id: string]: TIssueReaction; }; export type TIssueReactionIdMap = { diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx index 2c4f950e6..4208256a0 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx @@ -6,6 +6,7 @@ import { useIssueDetail, useMention, useUser } from "hooks/store"; // components import { IssueCommentBlock } from "./comment-block"; import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor"; +import { IssueCommentReaction } from "../../reactions/issue-comment"; // ui import { CustomMenu } from "@plane/ui"; // services @@ -28,7 +29,7 @@ type TIssueCommentCard = { }; export const IssueCommentCard: FC = (props) => { - const { commentId, activityOperations, ends, showAccessSpecifier = true } = props; + const { workspaceSlug, commentId, activityOperations, ends, showAccessSpecifier = true } = props; // hooks const { comment: { getCommentById }, @@ -67,7 +68,7 @@ export const IssueCommentCard: FC = (props) => { isEditing && setFocus("comment_html"); }, [isEditing, setFocus]); - if (!comment) return <>; + if (!comment || !currentUser) return <>; return ( = (props) => { customClassName="text-xs border border-custom-border-200 bg-custom-background-100" mentionHighlights={mentionHighlights} /> - {/* */} + + diff --git a/web/components/issues/issue-detail/issue-activity/root.tsx b/web/components/issues/issue-detail/issue-activity/root.tsx index b2bafcffd..c76956395 100644 --- a/web/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/components/issues/issue-detail/issue-activity/root.tsx @@ -40,8 +40,6 @@ export type TActivityOperations = { createComment: (data: Partial) => Promise; updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; - createCommentReaction: (commentId: string, reaction: string) => Promise; - removeCommentReaction: (commentId: string, reaction: string) => Promise; }; export const IssueActivity: FC = observer((props) => { @@ -106,52 +104,8 @@ export const IssueActivity: FC = observer((props) => { }); } }, - createCommentReaction: async (commentId: string, reaction: string) => { - try { - if (!workspaceSlug || !projectId) throw new Error("Missing fields"); - await createCommentReaction(workspaceSlug, projectId, commentId, reaction); - setToastAlert({ - title: "Comment reaction added successfully.", - type: "success", - message: "Comment reaction added successfully.", - }); - } catch (error) { - setToastAlert({ - title: "Comment reaction addition failed.", - type: "error", - message: "Comment reaction addition failed. Please try again later.", - }); - } - }, - removeCommentReaction: async (commentId: string, reaction: string) => { - try { - if (!workspaceSlug || !projectId) throw new Error("Missing fields"); - await removeCommentReaction(workspaceSlug, projectId, commentId, reaction); - setToastAlert({ - title: "Comment reaction removed successfully.", - type: "success", - message: "Comment reaction removed successfully.", - }); - } catch (error) { - setToastAlert({ - title: "Comment reaction removal failed.", - type: "error", - message: "Comment reaction removal failed. Please try again later.", - }); - } - }, }), - [ - workspaceSlug, - projectId, - issueId, - createComment, - updateComment, - removeComment, - createCommentReaction, - removeCommentReaction, - setToastAlert, - ] + [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment, setToastAlert] ); const componentCommonProps = { @@ -167,7 +121,7 @@ export const IssueActivity: FC = observer((props) => {
Comments/Activity
{/* rendering activity */} -
+
{activityTabs.map((tab) => (
= observer((props) => {
{activityTab === "all" ? ( - <> +
- +
) : activityTab === "activity" ? ( ) : ( - <> +
- +
)}
diff --git a/web/components/issues/issue-detail/reactions/issue-comment.tsx b/web/components/issues/issue-detail/reactions/issue-comment.tsx new file mode 100644 index 000000000..30a8621e4 --- /dev/null +++ b/web/components/issues/issue-detail/reactions/issue-comment.tsx @@ -0,0 +1,118 @@ +import { FC, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { ReactionSelector } from "./reaction-selector"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { IUser } from "@plane/types"; +import { renderEmoji } from "helpers/emoji.helper"; + +export type TIssueCommentReaction = { + workspaceSlug: string; + projectId: string; + commentId: string; + currentUser: IUser; +}; + +export const IssueCommentReaction: FC = observer((props) => { + const { workspaceSlug, projectId, commentId, currentUser } = props; + + // hooks + const { + commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser }, + createCommentReaction, + removeCommentReaction, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + + const reactionIds = getCommentReactionsByCommentId(commentId); + const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction); + + const issueCommentReactionOperations = useMemo( + () => ({ + create: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields"); + await createCommentReaction(workspaceSlug, projectId, commentId, reaction); + setToastAlert({ + title: "Reaction created successfully", + type: "success", + message: "Reaction created successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction creation failed", + type: "error", + message: "Reaction creation failed", + }); + } + }, + remove: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields"); + removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id); + setToastAlert({ + title: "Reaction removed successfully", + type: "success", + message: "Reaction removed successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction remove failed", + type: "error", + message: "Reaction remove failed", + }); + } + }, + react: async (reaction: string) => { + if (userReactions.includes(reaction)) await issueCommentReactionOperations.remove(reaction); + else await issueCommentReactionOperations.create(reaction); + }, + }), + [ + workspaceSlug, + projectId, + commentId, + currentUser, + createCommentReaction, + removeCommentReaction, + setToastAlert, + userReactions, + ] + ); + + return ( +
+ + + {reactionIds && + Object.keys(reactionIds || {}).map( + (reaction) => + reactionIds[reaction]?.length > 0 && ( + <> + + + ) + )} +
+ ); +}); diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx index 1627a6730..d6b33e36b 100644 --- a/web/components/issues/issue-detail/reactions/issue.tsx +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -50,7 +50,7 @@ export const IssueReaction: FC = observer((props) => { }, remove: async (reaction: string) => { try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + if (!workspaceSlug || !projectId || !issueId || !currentUser?.id) throw new Error("Missing fields"); await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id); setToastAlert({ title: "Reaction removed successfully", diff --git a/web/store/issue/issue-details/comment.store.ts b/web/store/issue/issue-details/comment.store.ts index dcc27b4df..a9fd82a67 100644 --- a/web/store/issue/issue-details/comment.store.ts +++ b/web/store/issue/issue-details/comment.store.ts @@ -101,6 +101,7 @@ export class IssueCommentStore implements IIssueCommentStore { return uniq(concat(_commentIds, commentIds)); }); comments.forEach((comment) => { + this.rootIssueDetail.commentReaction.applyCommentReactions(comment.id, comment?.comment_reactions || []); set(this.commentMap, comment.id, comment); }); this.loader = undefined; diff --git a/web/store/issue/issue-details/comment_reaction.store.ts b/web/store/issue/issue-details/comment_reaction.store.ts index 3cf629456..82ccf537f 100644 --- a/web/store/issue/issue-details/comment_reaction.store.ts +++ b/web/store/issue/issue-details/comment_reaction.store.ts @@ -1,10 +1,16 @@ import { action, makeObservable, observable, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import concat from "lodash/concat"; +import find from "lodash/find"; +import pull from "lodash/pull"; // services import { IssueReactionService } from "services/issue"; // types import { IIssueDetail } from "./root.store"; import { TIssueCommentReaction, TIssueCommentReactionIdMap, TIssueCommentReactionMap } from "@plane/types"; +// helpers +import { groupReactions } from "helpers/emoji.helper"; export interface IIssueCommentReactionStoreActions { // actions @@ -13,6 +19,7 @@ export interface IIssueCommentReactionStoreActions { projectId: string, commentId: string ) => Promise; + applyCommentReactions: (commentId: string, commentReactions: TIssueCommentReaction[]) => void; createCommentReaction: ( workspaceSlug: string, projectId: string, @@ -23,7 +30,8 @@ export interface IIssueCommentReactionStoreActions { workspaceSlug: string, projectId: string, commentId: string, - reaction: string + reaction: string, + userId: string ) => Promise; } @@ -32,8 +40,9 @@ export interface IIssueCommentReactionStore extends IIssueCommentReactionStoreAc commentReactions: TIssueCommentReactionIdMap; commentReactionMap: TIssueCommentReactionMap; // helper methods - getCommentReactionsByCommentId: (commentId: string) => string[] | undefined; + getCommentReactionsByCommentId: (commentId: string) => { [reaction_id: string]: string[] } | undefined; getCommentReactionById: (reactionId: string) => TIssueCommentReaction | undefined; + commentReactionsByUser: (commentId: string, userId: string) => TIssueCommentReaction[]; } export class IssueCommentReactionStore implements IIssueCommentReactionStore { @@ -52,6 +61,7 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { commentReactionMap: observable, // actions fetchCommentReactions: action, + applyCommentReactions: action, createCommentReaction: action, removeCommentReaction: action, }); @@ -72,25 +82,66 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { return this.commentReactionMap[reactionId] ?? undefined; }; + commentReactionsByUser = (commentId: string, userId: string) => { + if (!commentId || !userId) return []; + + const reactions = this.getCommentReactionsByCommentId(commentId); + if (!reactions) return []; + + const _userReactions: TIssueCommentReaction[] = []; + Object.keys(reactions).forEach((reaction) => { + reactions[reaction].map((reactionId) => { + const currentReaction = this.getCommentReactionById(reactionId); + if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction); + }); + }); + + return _userReactions; + }; + // actions fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) => { try { - const reactions = await this.issueReactionService.listIssueCommentReactions(workspaceSlug, projectId, commentId); + const response = await this.issueReactionService.listIssueCommentReactions(workspaceSlug, projectId, commentId); - const reactionIds = reactions.map((reaction) => reaction.id); - runInAction(() => { - set(this.commentReactions, commentId, reactionIds); - reactions.forEach((reaction) => { - set(this.commentReactionMap, reaction.id, reaction); - }); + const groupedReactions = groupReactions(response || [], "reaction"); + + const commentReactionIdsMap: { [reaction: string]: string[] } = {}; + + Object.keys(groupedReactions).map((reactionId) => { + const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id); + commentReactionIdsMap[reactionId] = reactionIds; }); - return reactions; + runInAction(() => { + set(this.commentReactions, commentId, commentReactionIdsMap); + response.forEach((reaction) => set(this.commentReactionMap, reaction.id, reaction)); + }); + + return response; } catch (error) { throw error; } }; + applyCommentReactions = (commentId: string, commentReactions: TIssueCommentReaction[]) => { + const groupedReactions = groupReactions(commentReactions || [], "reaction"); + + const commentReactionIdsMap: { [reaction: string]: string[] } = {}; + + Object.keys(groupedReactions).map((reactionId) => { + const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id); + commentReactionIdsMap[reactionId] = reactionIds; + }); + + runInAction(() => { + set(this.commentReactions, commentId, commentReactionIdsMap); + commentReactions.forEach((reaction) => set(this.commentReactionMap, reaction.id, reaction)); + }); + + return; + }; + createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => { try { const response = await this.issueReactionService.createIssueCommentReaction(workspaceSlug, projectId, commentId, { @@ -98,7 +149,10 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { }); runInAction(() => { - this.commentReactions[commentId].push(response.id); + update(this.commentReactions, [commentId, reaction], (reactionId) => { + if (!reactionId) return [response.id]; + return concat(reactionId, response.id); + }); set(this.commentReactionMap, response.id, response); }); @@ -108,14 +162,23 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { } }; - removeCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => { + removeCommentReaction = async ( + workspaceSlug: string, + projectId: string, + commentId: string, + reaction: string, + userId: string + ) => { try { - const reactionIndex = this.commentReactions[commentId].findIndex((_reaction) => _reaction === reaction); - if (reactionIndex >= 0) + const userReactions = this.commentReactionsByUser(commentId, userId); + const currentReaction = find(userReactions, { actor: userId, reaction: reaction }); + + if (currentReaction && currentReaction.id) { runInAction(() => { - this.commentReactions[commentId].splice(reactionIndex, 1); + pull(this.commentReactions[commentId][reaction], currentReaction.id); delete this.commentReactionMap[reaction]; }); + } const response = await this.issueReactionService.deleteIssueCommentReaction( workspaceSlug, diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index b696d87e2..21f66adee 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -16,7 +16,7 @@ import { IIssueCommentReactionStoreActions, } from "./comment_reaction.store"; -import { TIssue, TIssueComment, TIssueLink, TIssueRelationTypes } from "@plane/types"; +import { TIssue, TIssueComment, TIssueCommentReaction, TIssueLink, TIssueRelationTypes } from "@plane/types"; export type TPeekIssue = { workspaceSlug: string; @@ -238,8 +238,15 @@ export class IssueDetail implements IIssueDetail { // comment reaction fetchCommentReactions = async (workspaceSlug: string, projectId: string, commentId: string) => this.commentReaction.fetchCommentReactions(workspaceSlug, projectId, commentId); + applyCommentReactions = async (commentId: string, commentReactions: TIssueCommentReaction[]) => + this.commentReaction.applyCommentReactions(commentId, commentReactions); createCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => this.commentReaction.createCommentReaction(workspaceSlug, projectId, commentId, reaction); - removeCommentReaction = async (workspaceSlug: string, projectId: string, commentId: string, reaction: string) => - this.commentReaction.removeCommentReaction(workspaceSlug, projectId, commentId, reaction); + removeCommentReaction = async ( + workspaceSlug: string, + projectId: string, + commentId: string, + reaction: string, + userId: string + ) => this.commentReaction.removeCommentReaction(workspaceSlug, projectId, commentId, reaction, userId); }