[WEB-4320] refactor: migrate emoji reactions to propel (#8039)

This commit is contained in:
Anmol Singh Bhatia 2025-11-06 18:25:43 +05:30 committed by GitHub
parent d709465a89
commit fd38b9b6d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 433 additions and 563 deletions

View file

@ -1,15 +1,16 @@
"use client";
import type { FC } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// helpers
import { renderEmoji } from "@/helpers/emoji.helper";
// local imports
import { ReactionSelector } from "../issues/issue-detail/reactions";
export type TProps = {
comment: TIssueComment;
@ -19,49 +20,66 @@ export type TProps = {
export const CommentReactions: FC<TProps> = observer((props) => {
const { comment, activityOperations, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const userReactions = activityOperations.userReactions(comment.id);
const reactionIds = activityOperations.reactionIds(comment.id);
if (!userReactions) return null;
return (
<div className="relative flex items-center gap-1.5">
{!disabled && (
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={(reactionEmoji) => activityOperations.react(comment.id, reactionEmoji, userReactions)}
/>
)}
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction: string) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={activityOperations.getReactionUsers(reaction, reactionIds)}>
<button
type="button"
onClick={() => !disabled && activityOperations.react(comment.id, reaction, userReactions)}
key={reaction}
className={cn(
"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",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => {
// Get user names for this reaction
const tooltipContent = activityOperations.getReactionUsers(reaction, reactionIds);
// Parse the tooltip content string to extract user names
const users = tooltipContent ? tooltipContent.split(", ") : [];
return {
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions?.includes(reaction) || false,
users: users,
};
});
}, [reactionIds, userReactions, activityOperations]);
const handleReactionClick = (emoji: string) => {
if (disabled || !userReactions) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
activityOperations.react(comment.id, reactionString, userReactions);
};
const handleEmojiSelect = (emoji: string) => {
if (!userReactions) return;
// emoji is already in decimal string format from EmojiReactionPicker
activityOperations.react(comment.id, emoji, userReactions);
};
if (!userReactions) return null;
return (
<div className="relative">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View file

@ -3,8 +3,8 @@
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { ChevronRightIcon } from "@plane/propel/icons";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronRightIcon } from "@plane/propel/icons";
// icons
import { Row } from "@plane/ui";
// helpers

View file

@ -1,4 +1,2 @@
export * from "./reaction-selector";
export * from "./issue";
// export * from "./issue-comment";
export * from "./issue-comment";

View file

@ -1,20 +1,20 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUser } from "@plane/types";
// components
import { cn, formatTextList } from "@plane/utils";
// helper
import { renderEmoji } from "@/helpers/emoji.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
import { ReactionSelector } from "./reaction-selector";
export type TIssueCommentReaction = {
workspaceSlug: string;
@ -26,7 +26,8 @@ export type TIssueCommentReaction = {
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// hooks
const {
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
@ -82,7 +83,7 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props)
[workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions]
);
const getReactionUsers = (reaction: string): string => {
const getReactionUsers = (reaction: string): string[] => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
@ -91,48 +92,53 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props)
: null;
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
return reactionUsers;
};
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => ({
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions.includes(reaction),
users: getReactionUsers(reaction),
}));
}, [reactionIds, userReactions]);
const handleReactionClick = (emoji: string) => {
if (disabled) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
issueCommentReactionOperations.react(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
// emoji is already in decimal string format from EmojiReactionPicker
issueCommentReactionOperations.react(emoji);
};
return (
<div className="relative mt-4 flex items-center gap-1.5">
{!disabled && (
<ReactionSelector
size="md"
position="top"
value={userReactions}
onSelect={issueCommentReactionOperations.react}
/>
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={getReactionUsers(reaction)}>
<button
type="button"
onClick={() => !disabled && issueCommentReactionOperations.react(reaction)}
key={reaction}
className={cn(
"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",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
<div className="relative mt-4">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View file

@ -1,20 +1,20 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IUser } from "@plane/types";
// hooks
// ui
import { cn, formatTextList } from "@plane/utils";
// helpers
import { renderEmoji } from "@/helpers/emoji.helper";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
// types
import { ReactionSelector } from "./reaction-selector";
export type TIssueReaction = {
workspaceSlug: string;
@ -27,6 +27,8 @@ export type TIssueReaction = {
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// hooks
const {
reaction: { getReactionsByIssueId, reactionsByUser, getReactionById },
@ -82,7 +84,7 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions]
);
const getReactionUsers = (reaction: string): string => {
const getReactionUsers = (reaction: string): string[] => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getReactionById(reactionId);
@ -92,42 +94,53 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
})
.filter((displayName): displayName is string => !!displayName);
const formattedUsers = formatTextList(reactionUsers);
return formattedUsers;
return reactionUsers;
};
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => ({
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions.includes(reaction),
users: getReactionUsers(reaction),
}));
}, [reactionIds, userReactions]);
const handleReactionClick = (emoji: string) => {
if (disabled) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
issueReactionOperations.react(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
// emoji is already in decimal string format from EmojiReactionPicker
issueReactionOperations.react(emoji);
};
return (
<div className={cn("relative mt-4 flex items-center gap-1.5", className)}>
{!disabled && (
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
)}
{reactionIds &&
Object.keys(reactionIds || {}).map(
(reaction) =>
reactionIds[reaction]?.length > 0 && (
<>
<Tooltip tooltipContent={getReactionUsers(reaction)}>
<button
type="button"
onClick={() => !disabled && issueReactionOperations.react(reaction)}
key={reaction}
className={cn(
"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",
{
"cursor-not-allowed": disabled,
}
)}
>
<span>{renderEmoji(reaction)}</span>
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
{(reactionIds || {})[reaction].length}{" "}
</span>
</button>
</Tooltip>
</>
)
)}
<div className={cn("relative mt-4", className)}>
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View file

@ -1,74 +0,0 @@
import { Fragment } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "@/helpers/emoji.helper";
// icons
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> = (props) => {
const { onSelect, position, size } = props;
return (
<Popover className="relative">
{({ open, close: closePopover }) => (
<>
<Popover.Button
className={`${
open ? "" : "text-opacity-90"
} group inline-flex items-center rounded-md bg-custom-background-80 focus:outline-none`}
>
<span
className={`flex items-center justify-center rounded-md px-2 ${
size === "sm" ? "h-6 w-6" : size === "md" ? "h-7 w-7" : "h-8 w-8"
}`}
>
<SmilePlus className="h-3.5 w-3.5 text-custom-text-100" />
</span>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={`absolute z-10 bg-custom-sidebar-background-100 ${
position === "top" ? "-top-12" : "-bottom-12"
}`}
>
<div className="rounded-md border border-custom-border-200 bg-custom-sidebar-background-100 p-1">
<div className="flex gap-x-1">
{reactionEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onSelect(emoji);
closePopover();
}}
className="flex select-none items-center justify-between rounded-md p-1 text-sm hover:bg-custom-sidebar-background-90"
>
{renderEmoji(emoji)}
</button>
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
};

View file

@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import { CloseIcon } from "@plane/propel/icons";
// components
import { Logo } from "@plane/propel/emoji-icon-picker";
import { CloseIcon } from "@plane/propel/icons";
// hooks
import { useProject } from "@/hooks/store/use-project";