[WEB-4320] refactor: migrate emoji reactions to propel (#8039)
This commit is contained in:
parent
d709465a89
commit
fd38b9b6d8
22 changed files with 433 additions and 563 deletions
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
export * from "./reaction-selector";
|
||||
|
||||
export * from "./issue";
|
||||
// export * from "./issue-comment";
|
||||
export * from "./issue-comment";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue