[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
|
|
@ -37,7 +37,7 @@ export const BlockReactions = observer((props: Props) => {
|
||||||
)}
|
)}
|
||||||
{canReact && (
|
{canReact && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
|
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
|
||||||
// plane imports
|
// plane imports
|
||||||
import { cn } from "@plane/utils";
|
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
|
||||||
// ui
|
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
|
||||||
import { ReactionSelector } from "@/components/ui";
|
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
|
||||||
// helpers
|
// helpers
|
||||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
import { groupReactions } from "@/helpers/emoji.helper";
|
||||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||||
|
|
@ -23,6 +22,8 @@ type Props = {
|
||||||
|
|
||||||
export const CommentReactions: React.FC<Props> = observer((props) => {
|
export const CommentReactions: React.FC<Props> = observer((props) => {
|
||||||
const { anchor, commentId } = props;
|
const { anchor, commentId } = props;
|
||||||
|
// state
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -37,8 +38,18 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
|
||||||
const { data: user } = useUser();
|
const { data: user } = useUser();
|
||||||
const isInIframe = useIsInIframe();
|
const isInIframe = useIsInIframe();
|
||||||
|
|
||||||
const commentReactions = peekId ? details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions : [];
|
const commentReactions = useMemo(() => {
|
||||||
const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {};
|
if (!peekId) return [];
|
||||||
|
const peekDetails = details[peekId];
|
||||||
|
if (!peekDetails) return [];
|
||||||
|
const comment = peekDetails.comments?.find((c) => c.id === commentId);
|
||||||
|
return comment?.comment_reactions ?? [];
|
||||||
|
}, [peekId, details, commentId]);
|
||||||
|
|
||||||
|
const groupedReactions = useMemo(() => {
|
||||||
|
if (!peekId) return {};
|
||||||
|
return groupReactions(commentReactions ?? [], "reaction");
|
||||||
|
}, [peekId, commentReactions]);
|
||||||
|
|
||||||
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
|
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
|
||||||
|
|
||||||
|
|
@ -62,70 +73,68 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
|
||||||
// derived values
|
// derived values
|
||||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||||
|
|
||||||
|
// Transform reactions data to Propel EmojiReactionType format
|
||||||
|
const propelReactions: EmojiReactionType[] = useMemo(() => {
|
||||||
|
const REACTIONS_LIMIT = 1000;
|
||||||
|
|
||||||
|
return Object.keys(groupedReactions || {})
|
||||||
|
.filter((reaction) => groupedReactions?.[reaction]?.length > 0)
|
||||||
|
.map((reaction) => {
|
||||||
|
const reactionList = groupedReactions?.[reaction] ?? [];
|
||||||
|
const userNames = reactionList
|
||||||
|
.map((r) => r?.actor_detail?.display_name)
|
||||||
|
.filter((name): name is string => !!name)
|
||||||
|
.slice(0, REACTIONS_LIMIT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emoji: stringToEmoji(reaction),
|
||||||
|
count: reactionList.length,
|
||||||
|
reacted: commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction) || false,
|
||||||
|
users: userNames,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [groupedReactions, commentReactions, user?.id]);
|
||||||
|
|
||||||
|
const handleEmojiClick = (emoji: string) => {
|
||||||
|
if (isInIframe) return;
|
||||||
|
if (!user) {
|
||||||
|
router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert emoji back to decimal string format for the API
|
||||||
|
const emojiCodePoints = Array.from(emoji)
|
||||||
|
.map((char) => char.codePointAt(0))
|
||||||
|
.filter((cp): cp is number => cp !== undefined);
|
||||||
|
const reactionString = emojiCodePoints.join("-");
|
||||||
|
handleReactionClick(reactionString);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
|
if (!user) {
|
||||||
|
router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// emoji is already in decimal string format from EmojiReactionPicker
|
||||||
|
handleReactionClick(emoji);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex items-center gap-1.5">
|
<div className="mt-2">
|
||||||
{!isInIframe && (
|
<EmojiReactionPicker
|
||||||
<ReactionSelector
|
isOpen={isPickerOpen}
|
||||||
onSelect={(value) => {
|
handleToggle={setIsPickerOpen}
|
||||||
if (user) handleReactionClick(value);
|
onChange={handleEmojiSelect}
|
||||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
disabled={isInIframe}
|
||||||
}}
|
label={
|
||||||
position="top"
|
<EmojiReactionGroup
|
||||||
selected={userReactions?.map((r) => r.reaction)}
|
reactions={propelReactions}
|
||||||
size="md"
|
onReactionClick={handleEmojiClick}
|
||||||
/>
|
showAddButton={!isInIframe}
|
||||||
)}
|
onAddReaction={() => setIsPickerOpen(true)}
|
||||||
|
/>
|
||||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
}
|
||||||
const reactions = groupedReactions?.[reaction] ?? [];
|
placement="bottom-start"
|
||||||
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={() => {
|
|
||||||
if (isInIframe) return;
|
|
||||||
if (user) handleReactionClick(reaction);
|
|
||||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
|
||||||
commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction)
|
|
||||||
? "bg-custom-primary-100/10"
|
|
||||||
: "bg-custom-background-80"
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
"cursor-default": isInIframe,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>{renderEmoji(reaction)}</span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction)
|
|
||||||
? "text-custom-primary-100"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{groupedReactions?.[reaction].length}{" "}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
// lib
|
// lib
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
|
||||||
import { ReactionSelector } from "@/components/ui";
|
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
|
||||||
|
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
|
||||||
// helpers
|
// helpers
|
||||||
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
|
import { groupReactions } from "@/helpers/emoji.helper";
|
||||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
import { useIssueDetails } from "@/hooks/store/use-issue-details";
|
||||||
|
|
@ -15,11 +17,12 @@ import { useUser } from "@/hooks/store/use-user";
|
||||||
type IssueEmojiReactionsProps = {
|
type IssueEmojiReactionsProps = {
|
||||||
anchor: string;
|
anchor: string;
|
||||||
issueIdFromProps?: string;
|
issueIdFromProps?: string;
|
||||||
size?: "md" | "sm";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer((props) => {
|
export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer((props) => {
|
||||||
const { anchor, issueIdFromProps, size = "md" } = props;
|
const { anchor, issueIdFromProps } = props;
|
||||||
|
// state
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
|
@ -58,62 +61,63 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
||||||
|
|
||||||
// derived values
|
// derived values
|
||||||
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
|
||||||
const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1";
|
|
||||||
|
// Transform reactions data to Propel EmojiReactionType format
|
||||||
|
const propelReactions: EmojiReactionType[] = useMemo(() => {
|
||||||
|
const REACTIONS_LIMIT = 1000;
|
||||||
|
|
||||||
|
return Object.keys(groupedReactions || {})
|
||||||
|
.filter((reaction) => groupedReactions?.[reaction]?.length > 0)
|
||||||
|
.map((reaction) => {
|
||||||
|
const reactionList = groupedReactions?.[reaction] ?? [];
|
||||||
|
const userNames = reactionList
|
||||||
|
.map((r) => r?.actor_details?.display_name)
|
||||||
|
.filter((name): name is string => !!name)
|
||||||
|
.slice(0, REACTIONS_LIMIT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emoji: stringToEmoji(reaction),
|
||||||
|
count: reactionList.length,
|
||||||
|
reacted: reactionList.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction),
|
||||||
|
users: userNames,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [groupedReactions, user?.id]);
|
||||||
|
|
||||||
|
const handleEmojiClick = (emoji: string) => {
|
||||||
|
if (!user) {
|
||||||
|
router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||||
|
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("-");
|
||||||
|
handleReactionClick(reactionString);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
|
if (!user) {
|
||||||
|
router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// emoji is already in decimal string format from EmojiReactionPicker
|
||||||
|
handleReactionClick(emoji);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<EmojiReactionPicker
|
||||||
<ReactionSelector
|
isOpen={isPickerOpen}
|
||||||
onSelect={(value) => {
|
handleToggle={setIsPickerOpen}
|
||||||
if (user) handleReactionClick(value);
|
onChange={handleEmojiSelect}
|
||||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
label={
|
||||||
}}
|
<EmojiReactionGroup
|
||||||
selected={userReactions?.map((r) => r.reaction)}
|
reactions={propelReactions}
|
||||||
size={size}
|
onReactionClick={handleEmojiClick}
|
||||||
/>
|
showAddButton
|
||||||
{Object.keys(groupedReactions || {}).map((reaction) => {
|
onAddReaction={() => setIsPickerOpen(true)}
|
||||||
const reactions = groupedReactions?.[reaction] ?? [];
|
/>
|
||||||
const REACTIONS_LIMIT = 1000;
|
}
|
||||||
|
placement="bottom-start"
|
||||||
if (reactions.length > 0)
|
/>
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
key={reaction}
|
|
||||||
tooltipContent={
|
|
||||||
<div>
|
|
||||||
{reactions
|
|
||||||
?.map((r) => r?.actor_details?.display_name)
|
|
||||||
?.splice(0, REACTIONS_LIMIT)
|
|
||||||
?.join(", ")}
|
|
||||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (user) handleReactionClick(reaction);
|
|
||||||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-1 rounded-md text-sm text-custom-text-100 ${
|
|
||||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
|
||||||
? "bg-custom-primary-100/10"
|
|
||||||
: "bg-custom-background-80"
|
|
||||||
} ${reactionDimensions}`}
|
|
||||||
>
|
|
||||||
<span>{renderEmoji(reaction)}</span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
|
||||||
? "text-custom-primary-100"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{groupedReactions?.[reaction].length}{" "}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
export * from "./icon";
|
export * from "./icon";
|
||||||
export * from "./reaction-selector";
|
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { Fragment } from "react";
|
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
|
||||||
|
|
||||||
// helper
|
|
||||||
import { Icon } from "@/components/ui";
|
|
||||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
|
||||||
|
|
||||||
// icons
|
|
||||||
|
|
||||||
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelect: (emoji: string) => void;
|
|
||||||
position?: "top" | "bottom";
|
|
||||||
selected?: string[];
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReactionSelector: React.FC<Props> = (props) => {
|
|
||||||
const { onSelect, position, selected = [], 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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon iconName="add_reaction" className="scale-125 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 -left-2 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 shadow-custom-shadow-sm">
|
|
||||||
<div className="flex gap-x-1">
|
|
||||||
{reactionEmojis.map((emoji) => (
|
|
||||||
<button
|
|
||||||
key={emoji}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(emoji);
|
|
||||||
closePopover();
|
|
||||||
}}
|
|
||||||
className={`grid select-none place-items-center rounded-md p-1 text-sm ${
|
|
||||||
selected.includes(emoji) ? "bg-custom-primary-100/10" : "hover:bg-custom-sidebar-background-80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{renderEmoji(emoji)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover.Panel>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
// plane imports
|
// 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 type { TCommentsOperations, TIssueComment } from "@plane/types";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
|
||||||
// local imports
|
// local imports
|
||||||
import { ReactionSelector } from "../issues/issue-detail/reactions";
|
|
||||||
|
|
||||||
export type TProps = {
|
export type TProps = {
|
||||||
comment: TIssueComment;
|
comment: TIssueComment;
|
||||||
|
|
@ -19,49 +20,66 @@ export type TProps = {
|
||||||
|
|
||||||
export const CommentReactions: FC<TProps> = observer((props) => {
|
export const CommentReactions: FC<TProps> = observer((props) => {
|
||||||
const { comment, activityOperations, disabled = false } = props;
|
const { comment, activityOperations, disabled = false } = props;
|
||||||
|
// state
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
|
|
||||||
const userReactions = activityOperations.userReactions(comment.id);
|
const userReactions = activityOperations.userReactions(comment.id);
|
||||||
const reactionIds = activityOperations.reactionIds(comment.id);
|
const reactionIds = activityOperations.reactionIds(comment.id);
|
||||||
|
|
||||||
if (!userReactions) return null;
|
// Transform reactions data to Propel EmojiReactionType format
|
||||||
return (
|
const reactions: EmojiReactionType[] = useMemo(() => {
|
||||||
<div className="relative flex items-center gap-1.5">
|
if (!reactionIds) return [];
|
||||||
{!disabled && (
|
|
||||||
<ReactionSelector
|
|
||||||
size="md"
|
|
||||||
position="top"
|
|
||||||
value={userReactions}
|
|
||||||
onSelect={(reactionEmoji) => activityOperations.react(comment.id, reactionEmoji, userReactions)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{reactionIds &&
|
return Object.keys(reactionIds)
|
||||||
Object.keys(reactionIds || {}).map(
|
.filter((reaction) => reactionIds[reaction]?.length > 0)
|
||||||
(reaction: string) =>
|
.map((reaction) => {
|
||||||
reactionIds[reaction]?.length > 0 && (
|
// Get user names for this reaction
|
||||||
<>
|
const tooltipContent = activityOperations.getReactionUsers(reaction, reactionIds);
|
||||||
<Tooltip tooltipContent={activityOperations.getReactionUsers(reaction, reactionIds)}>
|
// Parse the tooltip content string to extract user names
|
||||||
<button
|
const users = tooltipContent ? tooltipContent.split(", ") : [];
|
||||||
type="button"
|
|
||||||
onClick={() => !disabled && activityOperations.react(comment.id, reaction, userReactions)}
|
return {
|
||||||
key={reaction}
|
emoji: stringToEmoji(reaction),
|
||||||
className={cn(
|
count: reactionIds[reaction].length,
|
||||||
"flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100",
|
reacted: userReactions?.includes(reaction) || false,
|
||||||
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80",
|
users: users,
|
||||||
{
|
};
|
||||||
"cursor-not-allowed": disabled,
|
});
|
||||||
}
|
}, [reactionIds, userReactions, activityOperations]);
|
||||||
)}
|
|
||||||
>
|
const handleReactionClick = (emoji: string) => {
|
||||||
<span>{renderEmoji(reaction)}</span>
|
if (disabled || !userReactions) return;
|
||||||
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
|
// Convert emoji back to decimal string format for the API
|
||||||
{(reactionIds || {})[reaction].length}{" "}
|
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
|
||||||
</span>
|
const reactionString = emojiCodePoints.join("-");
|
||||||
</button>
|
activityOperations.react(comment.id, reactionString, userReactions);
|
||||||
</Tooltip>
|
};
|
||||||
</>
|
|
||||||
)
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { ChevronRightIcon } from "@plane/propel/icons";
|
|
||||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||||
|
import { ChevronRightIcon } from "@plane/propel/icons";
|
||||||
// icons
|
// icons
|
||||||
import { Row } from "@plane/ui";
|
import { Row } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,2 @@
|
||||||
export * from "./reaction-selector";
|
|
||||||
|
|
||||||
export * from "./issue";
|
export * from "./issue";
|
||||||
// export * from "./issue-comment";
|
export * from "./issue-comment";
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
|
||||||
import type { IUser } from "@plane/types";
|
import type { IUser } from "@plane/types";
|
||||||
// components
|
// components
|
||||||
import { cn, formatTextList } from "@plane/utils";
|
import { cn, formatTextList } from "@plane/utils";
|
||||||
// helper
|
// helper
|
||||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
// types
|
// types
|
||||||
import { ReactionSelector } from "./reaction-selector";
|
|
||||||
|
|
||||||
export type TIssueCommentReaction = {
|
export type TIssueCommentReaction = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -26,7 +26,8 @@ export type TIssueCommentReaction = {
|
||||||
|
|
||||||
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
|
export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
|
const { workspaceSlug, projectId, commentId, currentUser, disabled = false } = props;
|
||||||
|
// state
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
const {
|
||||||
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
|
commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById },
|
||||||
|
|
@ -82,7 +83,7 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props)
|
||||||
[workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions]
|
[workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReactionUsers = (reaction: string): string => {
|
const getReactionUsers = (reaction: string): string[] => {
|
||||||
const reactionUsers = (reactionIds?.[reaction] || [])
|
const reactionUsers = (reactionIds?.[reaction] || [])
|
||||||
.map((reactionId) => {
|
.map((reactionId) => {
|
||||||
const reactionDetails = getCommentReactionById(reactionId);
|
const reactionDetails = getCommentReactionById(reactionId);
|
||||||
|
|
@ -91,48 +92,53 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props)
|
||||||
: null;
|
: null;
|
||||||
})
|
})
|
||||||
.filter((displayName): displayName is string => !!displayName);
|
.filter((displayName): displayName is string => !!displayName);
|
||||||
const formattedUsers = formatTextList(reactionUsers);
|
return reactionUsers;
|
||||||
return formattedUsers;
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="relative mt-4 flex items-center gap-1.5">
|
<div className="relative mt-4">
|
||||||
{!disabled && (
|
<EmojiReactionPicker
|
||||||
<ReactionSelector
|
isOpen={isPickerOpen}
|
||||||
size="md"
|
handleToggle={setIsPickerOpen}
|
||||||
position="top"
|
onChange={handleEmojiSelect}
|
||||||
value={userReactions}
|
disabled={disabled}
|
||||||
onSelect={issueCommentReactionOperations.react}
|
label={
|
||||||
/>
|
<EmojiReactionGroup
|
||||||
)}
|
reactions={reactions}
|
||||||
|
onReactionClick={handleReactionClick}
|
||||||
{reactionIds &&
|
showAddButton={!disabled}
|
||||||
Object.keys(reactionIds || {}).map(
|
onAddReaction={() => setIsPickerOpen(true)}
|
||||||
(reaction) =>
|
/>
|
||||||
reactionIds[reaction]?.length > 0 && (
|
}
|
||||||
<>
|
placement="bottom-start"
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { observer } from "mobx-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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import { Tooltip } from "@plane/propel/tooltip";
|
|
||||||
import type { IUser } from "@plane/types";
|
import type { IUser } from "@plane/types";
|
||||||
// hooks
|
// hooks
|
||||||
// ui
|
// ui
|
||||||
import { cn, formatTextList } from "@plane/utils";
|
import { cn, formatTextList } from "@plane/utils";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderEmoji } from "@/helpers/emoji.helper";
|
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
// types
|
// types
|
||||||
import { ReactionSelector } from "./reaction-selector";
|
|
||||||
|
|
||||||
export type TIssueReaction = {
|
export type TIssueReaction = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -27,6 +27,8 @@ export type TIssueReaction = {
|
||||||
|
|
||||||
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
|
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
|
const { workspaceSlug, projectId, issueId, currentUser, disabled = false, className = "" } = props;
|
||||||
|
// state
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
const {
|
||||||
reaction: { getReactionsByIssueId, reactionsByUser, getReactionById },
|
reaction: { getReactionsByIssueId, reactionsByUser, getReactionById },
|
||||||
|
|
@ -82,7 +84,7 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
|
||||||
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions]
|
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReactionUsers = (reaction: string): string => {
|
const getReactionUsers = (reaction: string): string[] => {
|
||||||
const reactionUsers = (reactionIds?.[reaction] || [])
|
const reactionUsers = (reactionIds?.[reaction] || [])
|
||||||
.map((reactionId) => {
|
.map((reactionId) => {
|
||||||
const reactionDetails = getReactionById(reactionId);
|
const reactionDetails = getReactionById(reactionId);
|
||||||
|
|
@ -92,42 +94,53 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => {
|
||||||
})
|
})
|
||||||
.filter((displayName): displayName is string => !!displayName);
|
.filter((displayName): displayName is string => !!displayName);
|
||||||
|
|
||||||
const formattedUsers = formatTextList(reactionUsers);
|
return reactionUsers;
|
||||||
return formattedUsers;
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className={cn("relative mt-4 flex items-center gap-1.5", className)}>
|
<div className={cn("relative mt-4", className)}>
|
||||||
{!disabled && (
|
<EmojiReactionPicker
|
||||||
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
|
isOpen={isPickerOpen}
|
||||||
)}
|
handleToggle={setIsPickerOpen}
|
||||||
{reactionIds &&
|
onChange={handleEmojiSelect}
|
||||||
Object.keys(reactionIds || {}).map(
|
disabled={disabled}
|
||||||
(reaction) =>
|
label={
|
||||||
reactionIds[reaction]?.length > 0 && (
|
<EmojiReactionGroup
|
||||||
<>
|
reactions={reactions}
|
||||||
<Tooltip tooltipContent={getReactionUsers(reaction)}>
|
onReactionClick={handleReactionClick}
|
||||||
<button
|
showAddButton={!disabled}
|
||||||
type="button"
|
onAddReaction={() => setIsPickerOpen(true)}
|
||||||
onClick={() => !disabled && issueReactionOperations.react(reaction)}
|
/>
|
||||||
key={reaction}
|
}
|
||||||
className={cn(
|
placement="bottom-start"
|
||||||
"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>
|
</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 { observer } from "mobx-react";
|
||||||
import { CloseIcon } from "@plane/propel/icons";
|
|
||||||
// components
|
|
||||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||||
|
import { CloseIcon } from "@plane/propel/icons";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ export interface AnimatedCounterProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: "text-xs h-4 w-4",
|
sm: "text-xs",
|
||||||
md: "text-sm h-5 w-5",
|
md: "text-sm",
|
||||||
lg: "text-base h-6 w-6",
|
lg: "text-base",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({ count, className, size = "md" }) => {
|
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({ count, className, size = "md" }) => {
|
||||||
|
|
@ -44,7 +44,7 @@ export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({ count, classNa
|
||||||
const sizeClass = sizeClasses[size];
|
const sizeClass = sizeClasses[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative inline-flex items-center justify-center overflow-hidden", sizeClass)}>
|
<div className={cn("relative inline-flex items-center justify-center overflow-hidden min-w-2", sizeClass)}>
|
||||||
{/* Previous number sliding out */}
|
{/* Previous number sliding out */}
|
||||||
{isAnimating && (
|
{isAnimating && (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export const EmojiPicker: React.FC<TCustomEmojiPicker> = (props) => {
|
||||||
side={finalSide}
|
side={finalSide}
|
||||||
align={finalAlign}
|
align={finalAlign}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
|
data-prevent-outside-click="true"
|
||||||
>
|
>
|
||||||
<Tabs.Root defaultValue={defaultOpen}>
|
<Tabs.Root defaultValue={defaultOpen}>
|
||||||
<Tabs.List className="grid grid-cols-2 gap-1 px-3.5 pt-3">
|
<Tabs.List className="grid grid-cols-2 gap-1 px-3.5 pt-3">
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Pick Emoji",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||||
|
|
@ -46,6 +52,12 @@ export const Default: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithCustomLabel: Story = {
|
export const WithCustomLabel: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Add Reaction",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||||
|
|
@ -71,6 +83,12 @@ export const WithCustomLabel: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InlineReactions: Story = {
|
export const InlineReactions: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Add",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||||
|
|
@ -128,6 +146,12 @@ export const InlineReactions: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DifferentPlacements: Story = {
|
export const DifferentPlacements: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Placements",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen1, setIsOpen1] = useState(false);
|
const [isOpen1, setIsOpen1] = useState(false);
|
||||||
const [isOpen2, setIsOpen2] = useState(false);
|
const [isOpen2, setIsOpen2] = useState(false);
|
||||||
|
|
@ -182,6 +206,12 @@ export const DifferentPlacements: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SearchDisabled: Story = {
|
export const SearchDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "No Search",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||||
|
|
@ -207,6 +237,12 @@ export const SearchDisabled: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomSearchPlaceholder: Story = {
|
export const CustomSearchPlaceholder: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Custom Search",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
||||||
|
|
@ -232,6 +268,12 @@ export const CustomSearchPlaceholder: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CloseOnSelectDisabled: Story = {
|
export const CloseOnSelectDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Select Multiple",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedEmojis, setSelectedEmojis] = useState<string[]>([]);
|
const [selectedEmojis, setSelectedEmojis] = useState<string[]>([]);
|
||||||
|
|
@ -279,6 +321,12 @@ export const CloseOnSelectDisabled: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InMessageContext: Story = {
|
export const InMessageContext: Story = {
|
||||||
|
args: {
|
||||||
|
isOpen: false,
|
||||||
|
handleToggle: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
label: "Message",
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ export const EmojiReactionPicker: React.FC<EmojiReactionPickerProps> = (props) =
|
||||||
side={finalSide}
|
side={finalSide}
|
||||||
align={finalAlign}
|
align={finalAlign}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
|
data-prevent-outside-click="true"
|
||||||
>
|
>
|
||||||
<div className="h-80 overflow-hidden overflow-y-auto">
|
<div className="h-80 overflow-hidden overflow-y-auto">
|
||||||
<EmojiRoot
|
<EmojiRoot
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ export const Reacted: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Interactive: Story = {
|
export const Interactive: Story = {
|
||||||
|
args: {
|
||||||
|
emoji: "👍",
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [reacted, setReacted] = useState(false);
|
const [reacted, setReacted] = useState(false);
|
||||||
const [count, setCount] = useState(5);
|
const [count, setCount] = useState(5);
|
||||||
|
|
@ -75,28 +79,11 @@ export const WithoutCount: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Sizes: Story = {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 items-center">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs text-custom-text-400">Small</span>
|
|
||||||
<EmojiReaction emoji="👍" count={5} size="sm" users={["Alice", "Bob"]} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs text-custom-text-400">Medium (default)</span>
|
|
||||||
<EmojiReaction emoji="👍" count={5} size="md" users={["Alice", "Bob"]} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs text-custom-text-400">Large</span>
|
|
||||||
<EmojiReaction emoji="👍" count={5} size="lg" users={["Alice", "Bob"]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MultipleReactions: Story = {
|
export const MultipleReactions: Story = {
|
||||||
|
args: {
|
||||||
|
emoji: "👍",
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||||
|
|
@ -137,6 +124,10 @@ export const MultipleReactions: Story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddButton: Story = {
|
export const AddButton: Story = {
|
||||||
|
args: {
|
||||||
|
emoji: "➕",
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
alert("Add reaction clicked");
|
alert("Add reaction clicked");
|
||||||
|
|
@ -146,28 +137,11 @@ export const AddButton: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddButtonSizes: Story = {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<div className="flex flex-col gap-2 items-center">
|
|
||||||
<span className="text-xs text-custom-text-400">Small</span>
|
|
||||||
<EmojiReactionButton size="sm" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 items-center">
|
|
||||||
<span className="text-xs text-custom-text-400">Medium</span>
|
|
||||||
<EmojiReactionButton size="md" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 items-center">
|
|
||||||
<span className="text-xs text-custom-text-400">Large</span>
|
|
||||||
<EmojiReactionButton size="lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReactionGroup: Story = {
|
export const ReactionGroup: Story = {
|
||||||
|
args: {
|
||||||
|
emoji: "👍",
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||||
|
|
@ -205,34 +179,11 @@ export const ReactionGroup: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReactionGroupSizes: Story = {
|
|
||||||
render() {
|
|
||||||
const reactions: EmojiReactionType[] = [
|
|
||||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob"] },
|
|
||||||
{ emoji: "❤️", count: 12, reacted: true, users: ["Charlie", "David"] },
|
|
||||||
{ emoji: "🎉", count: 3, reacted: false, users: ["Emma"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs text-custom-text-400">Small</span>
|
|
||||||
<EmojiReactionGroup reactions={reactions} size="sm" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs text-custom-text-400">Medium</span>
|
|
||||||
<EmojiReactionGroup reactions={reactions} size="md" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs text-custom-text-400">Large</span>
|
|
||||||
<EmojiReactionGroup reactions={reactions} size="lg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InMessageContext: Story = {
|
export const InMessageContext: Story = {
|
||||||
|
args: {
|
||||||
|
emoji: "👍",
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
render() {
|
render() {
|
||||||
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
const [reactions, setReactions] = useState<EmojiReactionType[]>([
|
||||||
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
{ emoji: "👍", count: 5, reacted: false, users: ["Alice", "Bob", "Charlie"] },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { AnimatedCounter } from "../animated-counter";
|
import { AnimatedCounter } from "../animated-counter";
|
||||||
import { stringToEmoji } from "../emoji-icon-picker";
|
import { stringToEmoji } from "../emoji-icon-picker";
|
||||||
|
import { AddReactionIcon } from "../icons";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
import { cn } from "../utils";
|
import { cn } from "../utils";
|
||||||
|
|
||||||
|
|
@ -20,7 +20,6 @@ export interface EmojiReactionProps extends React.ButtonHTMLAttributes<HTMLButto
|
||||||
onReactionClick?: (emoji: string) => void;
|
onReactionClick?: (emoji: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
showCount?: boolean;
|
showCount?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
|
@ -30,46 +29,15 @@ export interface EmojiReactionGroupProps extends React.HTMLAttributes<HTMLDivEle
|
||||||
className?: string;
|
className?: string;
|
||||||
showAddButton?: boolean;
|
showAddButton?: boolean;
|
||||||
maxDisplayUsers?: number;
|
maxDisplayUsers?: number;
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
onAddReaction?: () => void;
|
onAddReaction?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: {
|
|
||||||
button: "px-2 py-1 text-xs gap-1",
|
|
||||||
emoji: "text-sm",
|
|
||||||
count: "text-xs",
|
|
||||||
addButton: "h-6 w-6",
|
|
||||||
addIcon: "h-3 w-3",
|
|
||||||
},
|
|
||||||
md: {
|
|
||||||
button: "px-2.5 py-1.5 text-sm gap-1.5",
|
|
||||||
emoji: "text-base",
|
|
||||||
count: "text-sm",
|
|
||||||
addButton: "h-7 w-7",
|
|
||||||
addIcon: "h-3.5 w-3.5",
|
|
||||||
},
|
|
||||||
lg: {
|
|
||||||
button: "px-3 py-2 text-base gap-2",
|
|
||||||
emoji: "text-lg",
|
|
||||||
count: "text-base",
|
|
||||||
addButton: "h-8 w-8",
|
|
||||||
addIcon: "h-4 w-4",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
|
const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
|
||||||
(
|
({ emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, ...props }, ref) => {
|
||||||
{ emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, size = "md", ...props },
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const sizeClass = sizeClasses[size];
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
onReactionClick?.(emoji);
|
onReactionClick?.(emoji);
|
||||||
};
|
};
|
||||||
|
|
@ -96,9 +64,7 @@ const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-full border transition-all duration-200 hover:scale-105",
|
"inline-flex items-center rounded-full border px-1.5 text-xs gap-0.5 transition-all duration-200",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
|
|
||||||
sizeClass.button,
|
|
||||||
reacted
|
reacted
|
||||||
? "bg-custom-primary-100/10 border-custom-primary-100 text-custom-primary-100"
|
? "bg-custom-primary-100/10 border-custom-primary-100 text-custom-primary-100"
|
||||||
: "bg-custom-background-100 border-custom-border-200 text-custom-text-300 hover:border-custom-border-300 hover:bg-custom-background-90",
|
: "bg-custom-background-100 border-custom-border-200 text-custom-text-300 hover:border-custom-border-300 hover:bg-custom-background-90",
|
||||||
|
|
@ -106,8 +72,8 @@ const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className={sizeClass.emoji}>{emoji}</span>
|
<span className="text-base leading-unset">{emoji}</span>
|
||||||
{showCount && count > 0 && <AnimatedCounter count={count} size={size} className={sizeClass.count} />}
|
{showCount && count > 0 && <AnimatedCounter count={count} size="sm" className="text-xs leading-normal" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -120,42 +86,29 @@ const EmojiReaction = React.forwardRef<HTMLButtonElement, EmojiReactionProps>(
|
||||||
);
|
);
|
||||||
|
|
||||||
const EmojiReactionButton = React.forwardRef<HTMLButtonElement, EmojiReactionButtonProps>(
|
const EmojiReactionButton = React.forwardRef<HTMLButtonElement, EmojiReactionButtonProps>(
|
||||||
({ onAddReaction, className, size = "md", ...props }, ref) => {
|
({ onAddReaction, className, ...props }, ref) => (
|
||||||
const sizeClass = sizeClasses[size];
|
<button
|
||||||
|
ref={ref}
|
||||||
return (
|
onClick={onAddReaction}
|
||||||
<button
|
className={cn(
|
||||||
ref={ref}
|
"inline-flex items-center justify-center rounded-full border border-dashed border-custom-border-300",
|
||||||
onClick={onAddReaction}
|
"bg-custom-background-100 text-custom-text-400 transition-all duration-200",
|
||||||
className={cn(
|
"hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5",
|
||||||
"inline-flex items-center justify-center rounded-full border border-dashed border-custom-border-300",
|
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
|
||||||
"bg-custom-background-100 text-custom-text-400 transition-all duration-200",
|
"h-6 w-6",
|
||||||
"hover:border-custom-primary-100 hover:text-custom-primary-100 hover:bg-custom-primary-100/5",
|
className
|
||||||
"focus:outline-none focus:ring-2 focus:ring-custom-primary-100/20 focus:ring-offset-1",
|
)}
|
||||||
sizeClass.addButton,
|
title="Add reaction"
|
||||||
className
|
{...props}
|
||||||
)}
|
>
|
||||||
title="Add reaction"
|
<AddReactionIcon className="h-3 w-3" />
|
||||||
{...props}
|
</button>
|
||||||
>
|
)
|
||||||
<Plus className={sizeClass.addIcon} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const EmojiReactionGroup = React.forwardRef<HTMLDivElement, EmojiReactionGroupProps>(
|
const EmojiReactionGroup = React.forwardRef<HTMLDivElement, EmojiReactionGroupProps>(
|
||||||
(
|
(
|
||||||
{
|
{ reactions, onReactionClick, onAddReaction, className, showAddButton = true, maxDisplayUsers = 5, ...props },
|
||||||
reactions,
|
|
||||||
onReactionClick,
|
|
||||||
onAddReaction,
|
|
||||||
className,
|
|
||||||
showAddButton = true,
|
|
||||||
maxDisplayUsers = 5,
|
|
||||||
size = "md",
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
ref
|
||||||
) => (
|
) => (
|
||||||
<div ref={ref} className={cn("flex flex-wrap items-center gap-2", className)} {...props}>
|
<div ref={ref} className={cn("flex flex-wrap items-center gap-2", className)} {...props}>
|
||||||
|
|
@ -167,10 +120,9 @@ const EmojiReactionGroup = React.forwardRef<HTMLDivElement, EmojiReactionGroupPr
|
||||||
reacted={reaction.reacted}
|
reacted={reaction.reacted}
|
||||||
users={reaction.users?.slice(0, maxDisplayUsers)}
|
users={reaction.users?.slice(0, maxDisplayUsers)}
|
||||||
onReactionClick={onReactionClick}
|
onReactionClick={onReactionClick}
|
||||||
size={size}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{showAddButton && <EmojiReactionButton onAddReaction={onAddReaction} size={size} />}
|
{showAddButton && <EmojiReactionButton onAddReaction={onAddReaction} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
21
packages/propel/src/icons/actions/add-reaction-icon.tsx
Normal file
21
packages/propel/src/icons/actions/add-reaction-icon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { IconWrapper } from "../icon-wrapper";
|
||||||
|
import { ISvgIcons } from "../type";
|
||||||
|
|
||||||
|
export const AddReactionIcon: React.FC<ISvgIcons> = ({ color = "currentColor", ...rest }) => {
|
||||||
|
const clipPathId = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconWrapper color={color} clipPathId={clipPathId} {...rest}>
|
||||||
|
<path
|
||||||
|
d="M7.74205 1.11803C8.08705 1.11822 8.36704 1.39797 8.36705 1.74303C8.36705 2.08808 8.08706 2.36783 7.74205 2.36803C6.19489 2.36804 4.64941 2.95774 3.46959 4.13756C1.11022 6.49693 1.11033 10.3221 3.46959 12.6815C5.829 15.0409 9.65407 15.0409 12.0135 12.6815C13.424 11.271 13.9923 9.33686 13.7157 7.50279C13.6644 7.16166 13.899 6.84336 14.2401 6.79185C14.5814 6.74037 14.8995 6.97593 14.951 7.31725C15.2842 9.52673 14.6003 11.8624 12.8973 13.5653C10.0497 16.4129 5.43337 16.4129 2.5858 13.5653C-0.261624 10.7177 -0.26172 6.10129 2.5858 3.25377C4.00945 1.83012 5.87693 1.11804 7.74205 1.11803ZM5.57408 9.36705C5.57469 9.36783 5.57576 9.36938 5.57701 9.37096C5.58126 9.37632 5.58935 9.38606 5.60045 9.39928C5.62309 9.42625 5.65949 9.4679 5.70884 9.51939C5.80839 9.62327 5.9578 9.76446 6.15123 9.90514C6.54052 10.1882 7.08107 10.452 7.74205 10.452C8.40303 10.4519 8.94362 10.1882 9.33287 9.90514C9.52624 9.76448 9.67573 9.62324 9.77525 9.51939C9.82463 9.46786 9.86106 9.42619 9.88365 9.39928C9.89492 9.38584 9.90289 9.37626 9.90709 9.37096C9.90903 9.36851 9.90964 9.36657 9.91002 9.36607L9.90904 9.36705C10.1164 9.09209 10.5074 9.03728 10.7831 9.244C11.0248 9.42531 11.098 9.74773 10.9735 10.0106L10.9081 10.119L10.9051 10.1229C10.9039 10.1246 10.902 10.1265 10.9003 10.1288C10.8967 10.1334 10.8921 10.1393 10.8866 10.1463C10.8753 10.1606 10.86 10.18 10.8407 10.203C10.8021 10.2489 10.7477 10.3114 10.6776 10.3846C10.5376 10.5308 10.3321 10.7232 10.0672 10.9159C9.53994 11.2993 8.74724 11.7019 7.74205 11.702C6.73657 11.702 5.94323 11.2994 5.41588 10.9159C5.15117 10.7234 4.94653 10.5307 4.8065 10.3846C4.73627 10.3113 4.681 10.2489 4.64244 10.203C4.62313 10.18 4.6078 10.1606 4.59654 10.1463C4.59098 10.1393 4.58643 10.1334 4.58287 10.1288C4.58108 10.1264 4.57927 10.1246 4.57798 10.1229C4.57738 10.1221 4.57653 10.1216 4.57603 10.121L4.57506 10.119C4.36799 9.84291 4.42405 9.45114 4.70006 9.244C4.9759 9.03712 5.3668 9.09167 5.57408 9.36705ZM5.8397 5.45689C6.32285 5.50596 6.69989 5.91397 6.70006 6.41002C6.70006 6.93918 6.27117 7.36883 5.74205 7.369C5.21278 7.369 4.78306 6.93929 4.78306 6.41002C4.78324 5.8809 5.21288 5.45201 5.74205 5.45201L5.8397 5.45689ZM9.8397 5.45689C10.3228 5.50596 10.6999 5.91395 10.7001 6.41002C10.7001 6.93921 10.2711 7.36883 9.74205 7.369C9.21284 7.369 8.78306 6.93932 8.78306 6.41002C8.78324 5.88087 9.21294 5.45201 9.74205 5.45201L9.8397 5.45689Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.2486 4.875V3.5H10.8736C10.5284 3.5 10.2486 3.22018 10.2486 2.875C10.2486 2.52982 10.5284 2.25 10.8736 2.25H12.2486V0.875C12.2486 0.529822 12.5284 0.25 12.8736 0.25C13.2188 0.25 13.4986 0.529822 13.4986 0.875V2.25H14.8736C15.2188 2.25 15.4986 2.52982 15.4986 2.875C15.4986 3.22018 15.2188 3.5 14.8736 3.5H13.4986V4.875C13.4986 5.22018 13.2188 5.5 12.8736 5.5C12.5284 5.5 12.2486 5.22018 12.2486 4.875Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</IconWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./add-icon";
|
export * from "./add-icon";
|
||||||
|
export * from "./add-reaction-icon";
|
||||||
export * from "./close-icon";
|
export * from "./close-icon";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
export const ActionsIconsMap = [
|
export const ActionsIconsMap = [
|
||||||
{ icon: <Icon name="action.add" />, title: "AddIcon" },
|
{ icon: <Icon name="action.add" />, title: "AddIcon" },
|
||||||
|
{ icon: <Icon name="action.add-reaction" />, title: "AddReactionIcon" },
|
||||||
{ icon: <Icon name="action.close" />, title: "CloseIcon" },
|
{ icon: <Icon name="action.close" />, title: "CloseIcon" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AddReactionIcon } from "./actions";
|
||||||
import { AddIcon } from "./actions/add-icon";
|
import { AddIcon } from "./actions/add-icon";
|
||||||
import { CloseIcon } from "./actions/close-icon";
|
import { CloseIcon } from "./actions/close-icon";
|
||||||
import { ChevronDownIcon } from "./arrows/chevron-down";
|
import { ChevronDownIcon } from "./arrows/chevron-down";
|
||||||
|
|
@ -112,6 +113,7 @@ export const ICON_REGISTRY = {
|
||||||
|
|
||||||
// Action icons
|
// Action icons
|
||||||
"action.add": AddIcon,
|
"action.add": AddIcon,
|
||||||
|
"action.add-reaction": AddReactionIcon,
|
||||||
"action.close": CloseIcon,
|
"action.close": CloseIcon,
|
||||||
|
|
||||||
// Arrow icons
|
// Arrow icons
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue