diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index 543feaed30e..4baefbd5bf2 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -4,4 +4,5 @@ export * from "./sidebar"; export * from "./theme"; export * from "./views"; export * from "./feeds"; +export * from "./reaction-selector"; export * from "./image-picker-popover"; diff --git a/apps/app/components/core/reaction-selector.tsx b/apps/app/components/core/reaction-selector.tsx new file mode 100644 index 00000000000..585afa43002 --- /dev/null +++ b/apps/app/components/core/reaction-selector.tsx @@ -0,0 +1,85 @@ +import { Fragment } from "react"; + +// headless ui +import { Popover, Transition } from "@headlessui/react"; + +// helper +import { renderEmoji } from "helpers/emoji.helper"; + +// icons +import { Icon } from "components/ui"; + +const reactionEmojis = [ + "128077", + "128078", + "128516", + "128165", + "128533", + "129505", + "9992", + "128064", +]; + +interface Props { + size?: "sm" | "md" | "lg"; + position?: "top" | "bottom"; + value?: string | string[] | null; + onSelect: (emoji: string) => void; +} + +export const ReactionSelector: React.FC = (props) => { + const { value, onSelect, position, size } = props; + + return ( + + {({ open, close: closePopover }) => ( + <> + + + + + + + +
+
+ {reactionEmojis.map((emoji) => ( + + ))} +
+
+
+
+ + )} +
+ ); +}; diff --git a/apps/app/components/inbox/inbox-main-content.tsx b/apps/app/components/inbox/inbox-main-content.tsx index 49a3baf91aa..af123485943 100644 --- a/apps/app/components/inbox/inbox-main-content.tsx +++ b/apps/app/components/inbox/inbox-main-content.tsx @@ -19,6 +19,7 @@ import { IssueActivitySection, IssueDescriptionForm, IssueDetailsSidebar, + IssueReaction, } from "components/issues"; // ui import { Loader } from "components/ui"; @@ -303,6 +304,13 @@ export const InboxMainContent: React.FC = () => { } /> + + +

Comments/Activity

diff --git a/apps/app/components/issues/comment/comment-card.tsx b/apps/app/components/issues/comment/comment-card.tsx index a88b2abf9fb..a78be0fa1a2 100644 --- a/apps/app/components/issues/comment/comment-card.tsx +++ b/apps/app/components/issues/comment/comment-card.tsx @@ -10,6 +10,7 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/rea import useUser from "hooks/use-user"; // ui import { CustomMenu } from "components/ui"; +import { CommentReaction } from "components/issues"; // helpers import { timeAgo } from "helpers/date-time.helper"; // types @@ -138,6 +139,12 @@ export const CommentCard: React.FC = ({ comment, onSubmit, handleCommentD customClassName="text-xs border border-custom-border-200 bg-custom-background-100" ref={showEditorRef} /> + +
diff --git a/apps/app/components/issues/comment/comment-reaction.tsx b/apps/app/components/issues/comment/comment-reaction.tsx new file mode 100644 index 00000000000..d701fa943ec --- /dev/null +++ b/apps/app/components/issues/comment/comment-reaction.tsx @@ -0,0 +1,80 @@ +import React from "react"; + +// hooks +import useUser from "hooks/use-user"; +import useCommentReaction from "hooks/use-comment-reaction"; +// ui +import { ReactionSelector } from "components/core"; +// helper +import { renderEmoji } from "helpers/emoji.helper"; + +type Props = { + workspaceSlug?: string | string[]; + projectId?: string | string[]; + commentId: string; +}; + +export const CommentReaction: React.FC = (props) => { + const { workspaceSlug, projectId, commentId } = props; + + const { user } = useUser(); + + const { + commentReactions, + groupedReactions, + handleReactionCreate, + handleReactionDelete, + isLoading, + } = useCommentReaction(workspaceSlug, projectId, commentId); + + const handleReactionClick = (reaction: string) => { + if (!workspaceSlug || !projectId || !commentId) return; + + const isSelected = commentReactions?.some( + (r) => r.actor === user?.id && r.reaction === reaction + ); + + if (isSelected) { + handleReactionDelete(reaction); + } else { + handleReactionCreate(reaction); + } + }; + + return ( +
+ reaction.actor === user?.id) + .map((r) => r.reaction) || [] + } + onSelect={handleReactionClick} + /> + + {Object.keys(groupedReactions || {}).map( + (reaction) => + groupedReactions?.[reaction]?.length && + groupedReactions[reaction].length > 0 && ( + + ) + )} +
+ ); +}; diff --git a/apps/app/components/issues/comment/index.ts b/apps/app/components/issues/comment/index.ts index cf13ca91e39..61ac899ada6 100644 --- a/apps/app/components/issues/comment/index.ts +++ b/apps/app/components/issues/comment/index.ts @@ -1,2 +1,3 @@ export * from "./add-comment"; export * from "./comment-card"; +export * from "./comment-reaction"; diff --git a/apps/app/components/issues/index.ts b/apps/app/components/issues/index.ts index ea8cbc6039f..1c9fa9ddbbb 100644 --- a/apps/app/components/issues/index.ts +++ b/apps/app/components/issues/index.ts @@ -14,3 +14,4 @@ export * from "./parent-issues-list-modal"; export * from "./sidebar"; export * from "./sub-issues-list"; export * from "./label"; +export * from "./issue-reaction"; diff --git a/apps/app/components/issues/issue-reaction.tsx b/apps/app/components/issues/issue-reaction.tsx new file mode 100644 index 00000000000..d183685fec7 --- /dev/null +++ b/apps/app/components/issues/issue-reaction.tsx @@ -0,0 +1,70 @@ +// hooks +import useUserAuth from "hooks/use-user-auth"; +import useIssueReaction from "hooks/use-issue-reaction"; +// components +import { ReactionSelector } from "components/core"; +// string helpers +import { renderEmoji } from "helpers/emoji.helper"; + +// types +type Props = { + workspaceSlug?: string | string[]; + projectId?: string | string[]; + issueId?: string | string[]; +}; + +export const IssueReaction: React.FC = (props) => { + const { workspaceSlug, projectId, issueId } = props; + + const { user } = useUserAuth(); + + const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } = + useIssueReaction(workspaceSlug, projectId, issueId); + + const handleReactionClick = (reaction: string) => { + if (!workspaceSlug || !projectId || !issueId) return; + + const isSelected = reactions?.some((r) => r.actor === user?.id && r.reaction === reaction); + + if (isSelected) { + handleReactionDelete(reaction); + } else { + handleReactionCreate(reaction); + } + }; + + return ( +
+ reaction.actor === user?.id).map((r) => r.reaction) || [] + } + onSelect={handleReactionClick} + /> + + {Object.keys(groupedReactions || {}).map( + (reaction) => + groupedReactions?.[reaction]?.length && + groupedReactions[reaction].length > 0 && ( + + ) + )} +
+ ); +}; diff --git a/apps/app/components/issues/main-content.tsx b/apps/app/components/issues/main-content.tsx index 57052650ea2..5010d6d5bd7 100644 --- a/apps/app/components/issues/main-content.tsx +++ b/apps/app/components/issues/main-content.tsx @@ -17,6 +17,7 @@ import { IssueAttachments, IssueDescriptionForm, SubIssuesList, + IssueReaction, } from "components/issues"; // ui import { CustomMenu } from "components/ui"; @@ -117,6 +118,9 @@ export const IssueMainContent: React.FC = ({ handleFormSubmit={submitChanges} isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable} /> + + +
diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index dd36817aa8c..561bf336278 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -301,3 +301,13 @@ export const getPaginatedNotificationKey = ( cursor, })}`; }; + +export const ISSUE_REACTION_LIST = (workspaceSlug: string, projectId: string, issueId: string) => + `ISSUE_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId.toUpperCase()}`; + +export const COMMENT_REACTION_LIST = ( + workspaceSlug: string, + projectId: string, + commendId: string +) => + `COMMENT_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${commendId.toUpperCase()}`; diff --git a/apps/app/helpers/emoji.helper.tsx b/apps/app/helpers/emoji.helper.tsx index 7e4acd2ef4f..06bf8ba15a9 100644 --- a/apps/app/helpers/emoji.helper.tsx +++ b/apps/app/helpers/emoji.helper.tsx @@ -36,3 +36,18 @@ export const renderEmoji = ( ); else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); }; + +export const groupReactions: (reactions: any[], key: string) => { [key: string]: any[] } = ( + reactions: any, + key: string +) => { + const groupedReactions = reactions.reduce((acc: any, reaction: any) => { + if (!acc[reaction[key]]) { + acc[reaction[key]] = []; + } + acc[reaction[key]].push(reaction); + return acc; + }, {} as { [key: string]: any[] }); + + return groupedReactions; +}; diff --git a/apps/app/hooks/use-comment-reaction.tsx b/apps/app/hooks/use-comment-reaction.tsx new file mode 100644 index 00000000000..d52c65d09fd --- /dev/null +++ b/apps/app/hooks/use-comment-reaction.tsx @@ -0,0 +1,95 @@ +import useSWR from "swr"; + +// fetch keys +import { COMMENT_REACTION_LIST } from "constants/fetch-keys"; + +// services +import reactionService from "services/reaction.service"; + +// helpers +import { groupReactions } from "helpers/emoji.helper"; + +// hooks +import useUser from "./use-user"; + +const useCommentReaction = ( + workspaceSlug?: string | string[] | null, + projectId?: string | string[] | null, + commendId?: string | string[] | null +) => { + const { + data: commentReactions, + mutate: mutateCommentReactions, + error, + } = useSWR( + workspaceSlug && projectId && commendId + ? COMMENT_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), commendId.toString()) + : null, + workspaceSlug && projectId && commendId + ? () => + reactionService.listIssueCommentReactions( + workspaceSlug.toString(), + projectId.toString(), + commendId.toString() + ) + : null + ); + + const user = useUser(); + + const groupedReactions = groupReactions(commentReactions || [], "reaction"); + + /** + * @description Use this function to create user's reaction to an issue. This function will mutate the reactions state. + * @param {string} reaction + * @example handleReactionDelete("123") // 123 -> is emoji hexa-code + */ + + const handleReactionCreate = async (reaction: string) => { + if (!workspaceSlug || !projectId || !commendId) return; + + const data = await reactionService.createIssueCommentReaction( + workspaceSlug.toString(), + projectId.toString(), + commendId.toString(), + { reaction } + ); + + mutateCommentReactions((prev) => [...(prev || []), data]); + }; + + /** + * @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state. + * @param {string} reaction + * @example handleReactionDelete("123") // 123 -> is emoji hexa-code + */ + + const handleReactionDelete = async (reaction: string) => { + if (!workspaceSlug || !projectId || !commendId) return; + + mutateCommentReactions( + (prevData) => + prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [] + ); + + await reactionService.deleteIssueCommentReaction( + workspaceSlug.toString(), + projectId.toString(), + commendId.toString(), + reaction + ); + + mutateCommentReactions(); + }; + + return { + isLoading: !commentReactions && !error, + commentReactions, + groupedReactions, + handleReactionCreate, + handleReactionDelete, + mutateCommentReactions, + } as const; +}; + +export default useCommentReaction; diff --git a/apps/app/hooks/use-issue-reaction.tsx b/apps/app/hooks/use-issue-reaction.tsx new file mode 100644 index 00000000000..42a0dacdefa --- /dev/null +++ b/apps/app/hooks/use-issue-reaction.tsx @@ -0,0 +1,96 @@ +import useSWR from "swr"; + +// fetch keys +import { ISSUE_REACTION_LIST } from "constants/fetch-keys"; + +// helpers +import { groupReactions } from "helpers/emoji.helper"; + +// services +import reactionService from "services/reaction.service"; + +// hooks +import useUser from "./use-user"; + +const useIssueReaction = ( + workspaceSlug?: string | string[] | null, + projectId?: string | string[] | null, + issueId?: string | string[] | null +) => { + const user = useUser(); + + const { + data: reactions, + mutate: mutateReaction, + error, + } = useSWR( + workspaceSlug && projectId && issueId + ? ISSUE_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), issueId.toString()) + : null, + workspaceSlug && projectId && issueId + ? () => + reactionService.listIssueReactions( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString() + ) + : null + ); + + const groupedReactions = groupReactions(reactions || [], "reaction"); + + /** + * @description Use this function to create user's reaction to an issue. This function will mutate the reactions state. + * @param {string} reaction + * @example handleReactionCreate("128077") // hexa-code of the emoji + */ + + const handleReactionCreate = async (reaction: string) => { + if (!workspaceSlug || !projectId || !issueId) return; + + const data = await reactionService.createIssueReaction( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + { reaction } + ); + + mutateReaction((prev) => [...(prev || []), data]); + }; + + /** + * @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state. + * @param {string} reaction + * @example handleReactionDelete("123") // 123 -> is emoji hexa-code + */ + + const handleReactionDelete = async (reaction: string) => { + if (!workspaceSlug || !projectId || !issueId) return; + + mutateReaction( + (prevData) => + prevData?.filter((r) => r.actor !== user?.user?.id || r.reaction !== reaction) || [], + false + ); + + await reactionService.deleteIssueReaction( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + reaction + ); + + mutateReaction(); + }; + + return { + isLoading: !reactions && !error, + reactions, + groupedReactions, + handleReactionCreate, + handleReactionDelete, + mutateReaction, + } as const; +}; + +export default useIssueReaction; diff --git a/apps/app/services/reaction.service.ts b/apps/app/services/reaction.service.ts new file mode 100644 index 00000000000..3ba8a83e453 --- /dev/null +++ b/apps/app/services/reaction.service.ts @@ -0,0 +1,145 @@ +// services +import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; + +// types +import type { + ICurrentUserResponse, + IssueReaction, + IssueCommentReaction, + IssueReactionForm, + IssueCommentReactionForm, +} from "types"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + +class ReactionService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async createIssueReaction( + workspaceSlug: string, + projectId: string, + issueId: string, + data: IssueReactionForm, + user?: ICurrentUserResponse + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/reactions/`, + data + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackReactionEvent(response?.data, "ISSUE_REACTION_CREATE", user); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async listIssueReactions( + workspaceSlug: string, + projectId: string, + issueId: string + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/reactions/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteIssueReaction( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + user?: ICurrentUserResponse + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/reactions/${reaction}/` + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackReactionEvent(response?.data, "ISSUE_REACTION_DELETE", user); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async createIssueCommentReaction( + workspaceSlug: string, + projectId: string, + commentId: string, + data: IssueCommentReactionForm, + user?: ICurrentUserResponse + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/comments/${commentId}/reactions/`, + data + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackReactionEvent( + response?.data, + "ISSUE_COMMENT_REACTION_CREATE", + user + ); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async listIssueCommentReactions( + workspaceSlug: string, + projectId: string, + commentId: string + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/comments/${commentId}/reactions/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteIssueCommentReaction( + workspaceSlug: string, + projectId: string, + commentId: string, + reaction: string, + user?: ICurrentUserResponse + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/comments/${commentId}/reactions/${reaction}/` + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackReactionEvent( + response?.data, + "ISSUE_COMMENT_REACTION_DELETE", + user + ); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +const reactionService = new ReactionService(); + +export default reactionService; diff --git a/apps/app/services/track-event.service.ts b/apps/app/services/track-event.service.ts index bbc6a1a58bc..3da8b843640 100644 --- a/apps/app/services/track-event.service.ts +++ b/apps/app/services/track-event.service.ts @@ -19,6 +19,8 @@ import type { IState, IView, IWorkspace, + IssueCommentReaction, + IssueReaction, } from "types"; type WorkspaceEventType = @@ -110,6 +112,12 @@ type AnalyticsEventType = | "MODULE_CUSTOM_ANALYTICS" | "MODULE_ANALYTICS_EXPORT"; +type ReactionEventType = + | "ISSUE_REACTION_CREATE" + | "ISSUE_COMMENT_REACTION_CREATE" + | "ISSUE_REACTION_DELETE" + | "ISSUE_COMMENT_REACTION_DELETE"; + class TrackEventServices extends APIService { constructor() { super("/"); @@ -799,6 +807,32 @@ class TrackEventServices extends APIService { }, }); } + + async trackReactionEvent( + data: IssueReaction | IssueCommentReaction, + eventName: ReactionEventType, + user: ICurrentUserResponse | undefined + ): Promise { + let payload: any; + if (eventName === "ISSUE_REACTION_DELETE" || eventName === "ISSUE_COMMENT_REACTION_DELETE") + payload = data; + else + payload = { + workspaceId: data?.workspace, + projectId: data?.project, + reaction: data?.reaction, + }; + + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName, + extra: payload, + user: user, + }, + }); + } } const trackEventServices = new TrackEventServices(); diff --git a/apps/app/types/index.d.ts b/apps/app/types/index.d.ts index c2a8efbf1b9..dbd3148269d 100644 --- a/apps/app/types/index.d.ts +++ b/apps/app/types/index.d.ts @@ -17,6 +17,7 @@ export * from "./analytics"; export * from "./calendar"; export * from "./notifications"; export * from "./waitlist"; +export * from "./reaction"; export type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object diff --git a/apps/app/types/reaction.d.ts b/apps/app/types/reaction.d.ts new file mode 100644 index 00000000000..9e43496e7bb --- /dev/null +++ b/apps/app/types/reaction.d.ts @@ -0,0 +1,33 @@ +export interface IssueReaction { + id: string; + created_at: Date; + updated_at: Date; + reaction: string; + created_by: string; + updated_by: string; + project: string; + workspace: string; + actor: string; + issue: string; +} + +export interface IssueReactionForm { + reaction: string; +} + +export interface IssueCommentReaction { + id: string; + created_at: Date; + updated_at: Date; + reaction: string; + created_by: string; + updated_by: string; + project: string; + workspace: string; + actor: string; + comment: string; +} + +export interface IssueCommentReactionForm { + reaction: string; +}