Merge pull request #2100 from makeplane/feat/comment_reactions

feat: plane space comment reactions
This commit is contained in:
sriram veeraghanta 2023-09-06 14:52:55 +05:30 committed by GitHub
commit f11ae00201
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 340 additions and 47 deletions

View File

@ -1,17 +1,22 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useState } from "react";
import { useForm, Controller } from "react-hook-form";
// mobx
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// headless ui
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
// lib // lib
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// components
import { TipTapEditor } from "components/tiptap";
import { CommentReactions } from "components/issues/peek-overview";
// icons // icons
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline"; import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
// types // types
import { Comment } from "types/issue"; import { Comment } from "types/issue";
// components
import { TipTapEditor } from "components/tiptap";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -76,7 +81,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name} {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
</div> </div>
<p className="mt-0.5 text-xs text-custom-text-200"> <p className="mt-0.5 text-xs text-custom-text-200">
<>Commented {timeAgo(comment.created_at)}</> <>commented {timeAgo(comment.created_at)}</>
</p> </p>
</div> </div>
<div className="issue-comments-section p-0"> <div className="issue-comments-section p-0">
@ -125,6 +130,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
editable={false} editable={false}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100" customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/> />
<CommentReactions commentId={comment.id} projectId={comment.project} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,131 @@
import React from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { ReactionSelector, Tooltip } from "components/ui";
// helpers
import { groupReactions, renderEmoji } from "helpers/emoji.helper";
type Props = {
commentId: string;
projectId: string;
};
export const CommentReactions: React.FC<Props> = observer((props) => {
const { commentId, projectId } = props;
const router = useRouter();
const { workspace_slug } = router.query;
const { issueDetails: issueDetailsStore, user: userStore } = useMobxStore();
const peekId = issueDetailsStore.peekId;
const user = userStore.currentUser;
const commentReactions = peekId
? issueDetailsStore.details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions
: [];
const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {};
const userReactions = commentReactions?.filter((r) => r.actor_detail.id === user?.id);
const handleAddReaction = (reactionHex: string) => {
if (!workspace_slug || !projectId || !peekId) return;
issueDetailsStore.addCommentReaction(
workspace_slug.toString(),
projectId.toString(),
peekId,
commentId,
reactionHex
);
};
const handleRemoveReaction = (reactionHex: string) => {
if (!workspace_slug || !projectId || !peekId) return;
issueDetailsStore.removeCommentReaction(
workspace_slug.toString(),
projectId.toString(),
peekId,
commentId,
reactionHex
);
};
const handleReactionClick = (reactionHex: string) => {
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
if (userReaction) handleRemoveReaction(reactionHex);
else handleAddReaction(reactionHex);
};
return (
<div className="flex gap-1.5 items-center mt-2">
<ReactionSelector
onSelect={(value) => {
userStore.requiredLogin(() => {
handleReactionClick(value);
});
}}
position="top"
selected={userReactions?.map((r) => r.reaction)}
size="md"
/>
{Object.keys(groupedReactions || {}).map((reaction) => {
const reactions = groupedReactions?.[reaction] ?? [];
const REACTIONS_LIMIT = 1000;
if (reactions.length > 0)
return (
<Tooltip
key={reaction}
tooltipContent={
<div>
{reactions
.map((r) => r.actor_detail.display_name)
.splice(0, REACTIONS_LIMIT)
.join(", ")}
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
</div>
}
>
<button
type="button"
onClick={() => {
userStore.requiredLogin(() => {
handleReactionClick(reaction);
});
}}
className={`flex items-center gap-1 text-custom-text-100 text-sm h-full px-2 py-1 rounded-md ${
commentReactions?.some(
(r) => r.actor_detail.id === userStore.currentUser?.id && r.reaction === reaction
)
? "bg-custom-primary-100/10"
: "bg-custom-background-80"
}`}
>
<span>{renderEmoji(reaction)}</span>
<span
className={
commentReactions?.some(
(r) => r.actor_detail.id === userStore.currentUser?.id && r.reaction === reaction
)
? "text-custom-primary-100"
: ""
}
>
{groupedReactions?.[reaction].length}{" "}
</span>
</button>
</Tooltip>
);
})}
</div>
);
});

View File

@ -0,0 +1,3 @@
export * from "./add-comment";
export * from "./comment-detail-card";
export * from "./comment-reactions";

View File

@ -1,5 +1,7 @@
import React from "react"; import React from "react";
// mobx
import { observer } from "mobx-react-lite";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// hooks // hooks
@ -41,7 +43,7 @@ const peekModes: {
}, },
]; ];
export const PeekOverviewHeader: React.FC<Props> = (props) => { export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
const { handleClose, issueDetails } = props; const { handleClose, issueDetails } = props;
const { issueDetails: issueDetailStore }: RootStore = useMobxStore(); const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
@ -137,4 +139,4 @@ export const PeekOverviewHeader: React.FC<Props> = (props) => {
</div> </div>
</> </>
); );
}; });

View File

@ -1,3 +1,4 @@
export * from "./comment";
export * from "./full-screen-peek-view"; export * from "./full-screen-peek-view";
export * from "./header"; export * from "./header";
export * from "./issue-activity"; export * from "./issue-activity";
@ -8,5 +9,3 @@ export * from "./side-peek-view";
export * from "./issue-reaction"; export * from "./issue-reaction";
export * from "./issue-vote-reactions"; export * from "./issue-vote-reactions";
export * from "./issue-emoji-reactions"; export * from "./issue-emoji-reactions";
export * from "./comment-detail-card";
export * from "./add-comment";

View File

@ -20,18 +20,27 @@ export const IssueEmojiReactions: React.FC = observer(() => {
const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : [];
const groupedReactions = groupReactions(reactions, "reaction"); const groupedReactions = groupReactions(reactions, "reaction");
const handleReactionSelectClick = (reactionHex: string) => { const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id);
const handleAddReaction = (reactionHex: string) => {
if (!workspace_slug || !project_slug || !issueId) return; if (!workspace_slug || !project_slug || !issueId) return;
const userReaction = reactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
if (userReaction) return;
issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex); issueDetailsStore.addIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex);
}; };
const handleReactionClick = (reactionHex: string) => { const handleRemoveReaction = (reactionHex: string) => {
if (!workspace_slug || !project_slug || !issueId) return; if (!workspace_slug || !project_slug || !issueId) return;
issueDetailsStore.removeIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex); issueDetailsStore.removeIssueReaction(workspace_slug.toString(), project_slug.toString(), issueId, reactionHex);
}; };
const handleReactionClick = (reactionHex: string) => {
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
if (userReaction) handleRemoveReaction(reactionHex);
else handleAddReaction(reactionHex);
};
useEffect(() => { useEffect(() => {
if (user) return; if (user) return;
userStore.fetchCurrentUser(); userStore.fetchCurrentUser();
@ -42,9 +51,10 @@ export const IssueEmojiReactions: React.FC = observer(() => {
<ReactionSelector <ReactionSelector
onSelect={(value) => { onSelect={(value) => {
userStore.requiredLogin(() => { userStore.requiredLogin(() => {
handleReactionSelectClick(value); handleReactionClick(value);
}); });
}} }}
selected={userReactions?.map((r) => r.reaction)}
size="md" size="md"
/> />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">

View File

@ -12,13 +12,14 @@ import { Icon } from "components/ui";
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"]; const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props { interface Props {
size?: "sm" | "md" | "lg";
position?: "top" | "bottom";
onSelect: (emoji: string) => void; onSelect: (emoji: string) => void;
position?: "top" | "bottom";
selected?: string[];
size?: "sm" | "md" | "lg";
} }
export const ReactionSelector: React.FC<Props> = (props) => { export const ReactionSelector: React.FC<Props> = (props) => {
const { onSelect, position, size } = props; const { onSelect, position, selected = [], size } = props;
return ( return (
<Popover className="relative"> <Popover className="relative">
@ -51,7 +52,7 @@ export const ReactionSelector: React.FC<Props> = (props) => {
position === "top" ? "-top-12" : "-bottom-12" position === "top" ? "-top-12" : "-bottom-12"
}`} }`}
> >
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 rounded-md p-1"> <div className="bg-custom-sidebar-background-100 border border-custom-border-200 shadow-custom-shadow-sm rounded-md p-1">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
{reactionEmojis.map((emoji) => ( {reactionEmojis.map((emoji) => (
<button <button
@ -61,7 +62,9 @@ export const ReactionSelector: React.FC<Props> = (props) => {
onSelect(emoji); onSelect(emoji);
closePopover(); closePopover();
}} }}
className="flex select-none items-center justify-between rounded-md text-sm p-1 hover:bg-custom-sidebar-background-90" className={`grid place-items-center select-none rounded-md text-sm p-1 ${
selected.includes(emoji) ? "bg-custom-primary-100/10" : "hover:bg-custom-sidebar-background-80"
}`}
> >
{renderEmoji(emoji)} {renderEmoji(emoji)}
</button> </button>

View File

@ -93,16 +93,6 @@ class IssueService extends APIService {
}); });
} }
async getCommentsReactions(workspaceSlug: string, projectId: string, commentId: string): Promise<any> {
return this.get(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> { async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise<any> {
return this.post( return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`, `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`,
@ -140,6 +130,39 @@ class IssueService extends APIService {
throw error?.response; throw error?.response;
}); });
} }
async createCommentReaction(
workspaceSlug: string,
projectId: string,
commentId: string,
data: {
reaction: string;
}
): Promise<any> {
return this.post(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async deleteCommentReaction(
workspaceSlug: string,
projectId: string,
commentId: string,
reactionHex: string
): Promise<any> {
return this.delete(
`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/${reactionHex}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
} }
export default IssueService; export default IssueService;

View File

@ -32,6 +32,20 @@ export interface IIssueDetailStore {
data: any data: any
) => Promise<any>; ) => Promise<any>;
deleteIssueComment: (workspaceId: string, projectId: string, issueId: string, comment_id: string) => void; deleteIssueComment: (workspaceId: string, projectId: string, issueId: string, comment_id: string) => void;
addCommentReaction: (
workspaceId: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => void;
removeCommentReaction: (
workspaceId: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => void;
// issue reactions // issue reactions
addIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void; addIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void;
removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void; removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void;
@ -61,8 +75,17 @@ class IssueDetailStore implements IssueDetailStore {
details: observable.ref, details: observable.ref,
// actions // actions
setPeekId: action, setPeekId: action,
fetchIssueDetails: action,
setPeekMode: action, setPeekMode: action,
fetchIssueDetails: action,
addIssueComment: action,
updateIssueComment: action,
deleteIssueComment: action,
addCommentReaction: action,
removeCommentReaction: action,
addIssueReaction: action,
removeIssueReaction: action,
addIssueVote: action,
removeIssueVote: action,
}); });
this.issueService = new IssueService(); this.issueService = new IssueService();
this.rootStore = _rootStore; this.rootStore = _rootStore;
@ -175,6 +198,94 @@ class IssueDetailStore implements IssueDetailStore {
} }
}; };
addCommentReaction = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => {
const newReaction = {
id: uuidv4(),
comment: commentId,
reaction: reactionHex,
actor_detail: this.rootStore.user.currentActor,
};
const newComments = this.details[issueId].comments.map((comment) => ({
...comment,
comment_reactions:
comment.id === commentId ? [...comment.comment_reactions, newReaction] : comment.comment_reactions,
}));
try {
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: [...newComments],
},
};
});
await this.issueService.createCommentReaction(workspaceSlug, projectId, commentId, {
reaction: reactionHex,
});
} catch (error) {
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: issueComments,
},
};
});
}
};
removeCommentReaction = async (
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string,
reactionHex: string
) => {
try {
const comment = this.details[issueId].comments.find((c) => c.id === commentId);
const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? [];
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: this.details[issueId].comments.map((c) => ({
...c,
comment_reactions: c.id === commentId ? newCommentReactions : c.comment_reactions,
})),
},
};
});
await this.issueService.deleteCommentReaction(workspaceSlug, projectId, commentId, reactionHex);
} catch (error) {
const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId);
runInAction(() => {
this.details = {
...this.details,
[issueId]: {
...this.details[issueId],
comments: issueComments,
},
};
});
}
};
addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => { addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => {
try { try {
runInAction(() => { runInAction(() => {

View File

@ -68,25 +68,30 @@ export interface IVote {
} }
export interface Comment { export interface Comment {
id: string;
actor_detail: ActorDetail; actor_detail: ActorDetail;
issue_detail: IssueDetail;
project_detail: ProjectDetail;
workspace_detail: WorkspaceDetail;
comment_reactions: any[];
is_member: boolean;
created_at: Date;
updated_at: Date;
comment_stripped: string;
comment_html: string;
attachments: any[];
access: string; access: string;
created_by: string;
updated_by: string;
project: string;
workspace: string;
issue: string;
actor: string; actor: string;
attachments: any[];
comment_html: string;
comment_reactions: {
actor_detail: ActorDetail;
comment: string;
id: string;
reaction: string;
}[];
comment_stripped: string;
created_at: Date;
created_by: string;
id: string;
is_member: boolean;
issue: string;
issue_detail: IssueDetail;
project: string;
project_detail: ProjectDetail;
updated_at: Date;
updated_by: string;
workspace: string;
workspace_detail: WorkspaceDetail;
} }
export interface IIssueReaction { export interface IIssueReaction {

View File

@ -61,7 +61,7 @@ export const ReactionSelector: React.FC<Props> = (props) => {
position === "top" ? "-top-12" : "-bottom-12" position === "top" ? "-top-12" : "-bottom-12"
}`} }`}
> >
<div className="bg-custom-sidebar-background-100 border border-custom-border-200 rounded-md p-1"> <div className="bg-custom-sidebar-background-100 border border-custom-border-200 shadow-custom-shadow-sm rounded-md p-1">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
{reactionEmojis.map((emoji) => ( {reactionEmojis.map((emoji) => (
<button <button