chore: issue comment reaction

This commit is contained in:
gurusainath 2024-01-22 16:30:24 +05:30
parent 8f8b106a89
commit 8c63a9d7cd
9 changed files with 232 additions and 82 deletions

View File

@ -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[] };
};

View File

@ -13,7 +13,7 @@ export type TIssueReaction = {
};
export type TIssueReactionMap = {
[issue_id: string]: TIssueReaction;
[reaction_id: string]: TIssueReaction;
};
export type TIssueReactionIdMap = {

View File

@ -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<TIssueCommentCard> = (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<TIssueCommentCard> = (props) => {
isEditing && setFocus("comment_html");
}, [isEditing, setFocus]);
if (!comment) return <></>;
if (!comment || !currentUser) return <></>;
return (
<IssueCommentBlock
commentId={commentId}
@ -161,7 +162,13 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
mentionHighlights={mentionHighlights}
/>
{/* <CommentReaction projectId={comment.project} commentId={comment.id} /> */}
<IssueCommentReaction
workspaceSlug={workspaceSlug}
projectId={comment?.project_detail?.id}
commentId={comment.id}
currentUser={currentUser}
/>
</div>
</>
</IssueCommentBlock>

View File

@ -40,8 +40,6 @@ export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
createCommentReaction: (commentId: string, reaction: string) => Promise<void>;
removeCommentReaction: (commentId: string, reaction: string) => Promise<void>;
};
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
@ -106,52 +104,8 @@ export const IssueActivity: FC<TIssueActivity> = 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<TIssueActivity> = observer((props) => {
<div className="text-lg text-custom-text-100">Comments/Activity</div>
{/* rendering activity */}
<div className="space-y-2">
<div className="space-y-3">
<div className="relative flex items-center gap-1">
{activityTabs.map((tab) => (
<div
@ -190,25 +144,25 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
<div className="min-h-[200px]">
{activityTab === "all" ? (
<>
<div className="space-y-3">
<IssueActivityCommentRoot {...componentCommonProps} activityOperations={activityOperations} />
<IssueCommentCreateUpdate
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
disabled={disabled}
/>
</>
</div>
) : activityTab === "activity" ? (
<IssueActivityRoot {...componentCommonProps} />
) : (
<>
<div className="space-y-3">
<IssueCommentRoot {...componentCommonProps} activityOperations={activityOperations} />
<IssueCommentCreateUpdate
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
disabled={disabled}
/>
</>
</div>
)}
</div>
</div>

View File

@ -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<TIssueCommentReaction> = 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 (
<div className="mt-4 relative flex items-center gap-1.5">
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={issueCommentReactionOperations.react}
/>
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<button
type="button"
onClick={() => issueCommentReactionOperations.react(reaction)}
key={reaction}
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</>
)
)}
</div>
);
});

View File

@ -50,7 +50,7 @@ export const IssueReaction: FC<TIssueReaction> = 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",

View File

@ -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;

View File

@ -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<TIssueCommentReaction[]>;
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<any>;
}
@ -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,

View File

@ -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);
}