diff --git a/space/components/issues/peek-overview/add-comment.tsx b/space/components/issues/peek-overview/comment/add-comment.tsx similarity index 100% rename from space/components/issues/peek-overview/add-comment.tsx rename to space/components/issues/peek-overview/comment/add-comment.tsx diff --git a/space/components/issues/peek-overview/comment-detail-card.tsx b/space/components/issues/peek-overview/comment/comment-detail-card.tsx similarity index 96% rename from space/components/issues/peek-overview/comment-detail-card.tsx rename to space/components/issues/peek-overview/comment/comment-detail-card.tsx index a43942f4c..7c785d988 100644 --- a/space/components/issues/peek-overview/comment-detail-card.tsx +++ b/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -1,17 +1,22 @@ -import React, { useEffect, useState, useRef } from "react"; -import { useForm, Controller } from "react-hook-form"; +import React, { useState } from "react"; + +// mobx import { observer } from "mobx-react-lite"; +// react-hook-form +import { useForm, Controller } from "react-hook-form"; +// headless ui import { Menu, Transition } from "@headlessui/react"; // lib import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { TipTapEditor } from "components/tiptap"; +import { CommentReactions } from "components/issues/peek-overview"; // icons import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline"; // helpers import { timeAgo } from "helpers/date-time.helper"; // types import { Comment } from "types/issue"; -// components -import { TipTapEditor } from "components/tiptap"; type Props = { workspaceSlug: string; @@ -76,7 +81,7 @@ export const CommentCard: React.FC = observer((props) => { {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}

- <>Commented {timeAgo(comment.created_at)} + <>commented {timeAgo(comment.created_at)}

@@ -125,6 +130,7 @@ export const CommentCard: React.FC = observer((props) => { editable={false} customClassName="text-xs border border-custom-border-200 bg-custom-background-100" /> +
diff --git a/space/components/issues/peek-overview/comment/comment-reactions.tsx b/space/components/issues/peek-overview/comment/comment-reactions.tsx new file mode 100644 index 000000000..4045d3edf --- /dev/null +++ b/space/components/issues/peek-overview/comment/comment-reactions.tsx @@ -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 = 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 ( +
+ { + 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 ( + + {reactions + .map((r) => r.actor_detail.display_name) + .splice(0, REACTIONS_LIMIT) + .join(", ")} + {reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"} +
+ } + > + + + ); + })} + + ); +}); diff --git a/space/components/issues/peek-overview/comment/index.ts b/space/components/issues/peek-overview/comment/index.ts new file mode 100644 index 000000000..d217c0ddf --- /dev/null +++ b/space/components/issues/peek-overview/comment/index.ts @@ -0,0 +1,3 @@ +export * from "./add-comment"; +export * from "./comment-detail-card"; +export * from "./comment-reactions"; diff --git a/space/components/issues/peek-overview/header.tsx b/space/components/issues/peek-overview/header.tsx index 2aa43ff47..7a0b43b98 100644 --- a/space/components/issues/peek-overview/header.tsx +++ b/space/components/issues/peek-overview/header.tsx @@ -1,5 +1,7 @@ import React from "react"; +// mobx +import { observer } from "mobx-react-lite"; // headless ui import { Listbox, Transition } from "@headlessui/react"; // hooks @@ -41,7 +43,7 @@ const peekModes: { }, ]; -export const PeekOverviewHeader: React.FC = (props) => { +export const PeekOverviewHeader: React.FC = observer((props) => { const { handleClose, issueDetails } = props; const { issueDetails: issueDetailStore }: RootStore = useMobxStore(); @@ -137,4 +139,4 @@ export const PeekOverviewHeader: React.FC = (props) => { ); -}; +}); diff --git a/space/components/issues/peek-overview/index.ts b/space/components/issues/peek-overview/index.ts index 44ab04b88..f42253e5e 100644 --- a/space/components/issues/peek-overview/index.ts +++ b/space/components/issues/peek-overview/index.ts @@ -1,3 +1,4 @@ +export * from "./comment"; export * from "./full-screen-peek-view"; export * from "./header"; export * from "./issue-activity"; @@ -8,5 +9,3 @@ export * from "./side-peek-view"; export * from "./issue-reaction"; export * from "./issue-vote-reactions"; export * from "./issue-emoji-reactions"; -export * from "./comment-detail-card"; -export * from "./add-comment"; diff --git a/space/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/components/issues/peek-overview/issue-emoji-reactions.tsx index 3d2bfadac..b0c5b0361 100644 --- a/space/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -20,18 +20,27 @@ export const IssueEmojiReactions: React.FC = observer(() => { const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; 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; - 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); }; - const handleReactionClick = (reactionHex: string) => { + const handleRemoveReaction = (reactionHex: string) => { if (!workspace_slug || !project_slug || !issueId) return; + 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(() => { if (user) return; userStore.fetchCurrentUser(); @@ -42,9 +51,10 @@ export const IssueEmojiReactions: React.FC = observer(() => { { userStore.requiredLogin(() => { - handleReactionSelectClick(value); + handleReactionClick(value); }); }} + selected={userReactions?.map((r) => r.reaction)} size="md" />
diff --git a/space/components/ui/reaction-selector.tsx b/space/components/ui/reaction-selector.tsx index 70994460c..a7b67afa6 100644 --- a/space/components/ui/reaction-selector.tsx +++ b/space/components/ui/reaction-selector.tsx @@ -12,13 +12,14 @@ import { Icon } from "components/ui"; const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"]; interface Props { - size?: "sm" | "md" | "lg"; - position?: "top" | "bottom"; onSelect: (emoji: string) => void; + position?: "top" | "bottom"; + selected?: string[]; + size?: "sm" | "md" | "lg"; } export const ReactionSelector: React.FC = (props) => { - const { onSelect, position, size } = props; + const { onSelect, position, selected = [], size } = props; return ( @@ -51,7 +52,7 @@ export const ReactionSelector: React.FC = (props) => { position === "top" ? "-top-12" : "-bottom-12" }`} > -
+
{reactionEmojis.map((emoji) => ( diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts index 03a94654b..835778fb2 100644 --- a/space/services/issue.service.ts +++ b/space/services/issue.service.ts @@ -93,16 +93,6 @@ class IssueService extends APIService { }); } - async getCommentsReactions(workspaceSlug: string, projectId: string, commentId: string): Promise { - 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 { return this.post( `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`, @@ -140,6 +130,39 @@ class IssueService extends APIService { throw error?.response; }); } + + async createCommentReaction( + workspaceSlug: string, + projectId: string, + commentId: string, + data: { + reaction: string; + } + ): Promise { + 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 { + 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; diff --git a/space/store/issue_details.ts b/space/store/issue_details.ts index 10679cf17..26b6148c3 100644 --- a/space/store/issue_details.ts +++ b/space/store/issue_details.ts @@ -32,6 +32,20 @@ export interface IIssueDetailStore { data: any ) => Promise; 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 addIssueReaction: (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, // actions setPeekId: action, - fetchIssueDetails: 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.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) => { try { runInAction(() => { diff --git a/space/types/issue.ts b/space/types/issue.ts index 754da6ae3..206327fcd 100644 --- a/space/types/issue.ts +++ b/space/types/issue.ts @@ -68,25 +68,30 @@ export interface IVote { } export interface Comment { - id: string; 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; - created_by: string; - updated_by: string; - project: string; - workspace: string; - issue: 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 { diff --git a/web/components/core/reaction-selector.tsx b/web/components/core/reaction-selector.tsx index 06b410785..43d77de9e 100644 --- a/web/components/core/reaction-selector.tsx +++ b/web/components/core/reaction-selector.tsx @@ -61,7 +61,7 @@ export const ReactionSelector: React.FC = (props) => { position === "top" ? "-top-12" : "-bottom-12" }`} > -
+
{reactionEmojis.map((emoji) => (