Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f5faa8b
chore: create logo component in propel package
anmolsinghbhatia Oct 29, 2025
f6bd217
chore: migrate emoji components from @plane/ui to @plane/propel
anmolsinghbhatia Oct 29, 2025
41393a9
chore: remove emoji components and emoji-picker-react dependency
anmolsinghbhatia Oct 29, 2025
b8c9594
chore: update pnpm lockfile after emoji migration
anmolsinghbhatia Oct 29, 2025
57d5e74
Merge branch 'preview' of github.com:makeplane/plane into refactor-mi…
anmolsinghbhatia Oct 29, 2025
21f9f02
chore: add AddReactionIcon to icon library
anmolsinghbhatia Oct 30, 2025
506798d
chore: use relative imports and update emoji reaction button
anmolsinghbhatia Oct 30, 2025
585b010
chore: migrate web app emoji reactions to propel components
anmolsinghbhatia Oct 30, 2025
15fced3
chore: migrate space app emoji reactions to Propel components
anmolsinghbhatia Oct 30, 2025
986951c
fix: lint and format errors
anmolsinghbhatia Oct 30, 2025
d1e746f
chore: code refactor
anmolsinghbhatia Oct 30, 2025
e9d081b
chore: emoji picker, emoji reaction and animated counter propel compo…
anmolsinghbhatia Oct 30, 2025
e6fecef
chore: emoji picker and reaction component code refactor
anmolsinghbhatia Oct 30, 2025
93c5418
fix: peekview outside click event propagation issue
anmolsinghbhatia Oct 30, 2025
3a58bb1
chore: code refactor
anmolsinghbhatia Nov 3, 2025
860a7b4
fix: preview sync merge conflict
anmolsinghbhatia Nov 6, 2025
446d497
fix: preview sync merge conflict
anmolsinghbhatia Nov 6, 2025
c78c809
chore: code refactor
anmolsinghbhatia Nov 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const BlockReactions = observer((props: Props) => {
)}
{canReact && (
<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>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"use client";

import React from "react";
import React, { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Tooltip } from "@plane/propel/tooltip";
// plane imports
import { cn } from "@plane/utils";
// ui
import { ReactionSelector } from "@/components/ui";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
// helpers
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
import { groupReactions } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
Expand All @@ -23,6 +22,8 @@ type Props = {

export const CommentReactions: React.FC<Props> = observer((props) => {
const { anchor, commentId } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
Expand All @@ -37,8 +38,18 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
const { data: user } = useUser();
const isInIframe = useIsInIframe();

const commentReactions = peekId ? details[peekId].comments.find((c) => c.id === commentId)?.comment_reactions : [];
const groupedReactions = peekId ? groupReactions(commentReactions ?? [], "reaction") : {};
const commentReactions = useMemo(() => {
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);

Expand All @@ -62,70 +73,68 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
// derived values
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 (
<div className="mt-2 flex items-center gap-1.5">
{!isInIframe && (
<ReactionSelector
onSelect={(value) => {
if (user) handleReactionClick(value);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
position="top"
selected={userReactions?.map((r) => r.reaction)}
size="md"
/>
)}

{Object.keys(groupedReactions || {}).map((reaction) => {
const reactions = groupedReactions?.[reaction] ?? [];
const REACTIONS_LIMIT = 1000;

if (reactions.length > 0)
return (
<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 className="mt-2">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={isInIframe}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton={!isInIframe}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});
124 changes: 64 additions & 60 deletions apps/space/core/components/issues/reactions/issue-emoji-reactions.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use client";

import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
// lib
import { Tooltip } from "@plane/propel/tooltip";
import { ReactionSelector } from "@/components/ui";
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
// helpers
import { groupReactions, renderEmoji } from "@/helpers/emoji.helper";
import { groupReactions } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
Expand All @@ -15,11 +17,12 @@ import { useUser } from "@/hooks/store/use-user";
type IssueEmojiReactionsProps = {
anchor: string;
issueIdFromProps?: string;
size?: "md" | "sm";
};

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
const router = useRouter();
const pathName = usePathname();
Expand Down Expand Up @@ -58,62 +61,63 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(

// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1";

return (
<>
<ReactionSelector
onSelect={(value) => {
if (user) handleReactionClick(value);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
selected={userReactions?.map((r) => r.reaction)}
size={size}
/>
{Object.keys(groupedReactions || {}).map((reaction) => {
const reactions = groupedReactions?.[reaction] ?? [];
const REACTIONS_LIMIT = 1000;
// 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);
};

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>
);
})}
</>
return (
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
);
});
1 change: 0 additions & 1 deletion apps/space/core/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from "./icon";
export * from "./reaction-selector";
Loading
Loading