Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
"use client";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
// lib
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 } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
type IssueEmojiReactionsProps = {
anchor: string;
issueIdFromProps?: string;
};
export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer((props) => {
const { anchor, issueIdFromProps } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// router
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
// query params
const peekId = searchParams.get("peekId") || undefined;
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// store hooks
const issueDetailsStore = useIssueDetails();
const { data: user } = useUser();
const issueId = issueIdFromProps ?? issueDetailsStore.peekId;
const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? [];
const groupedReactions = groupReactions(reactions, "reaction");
const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id);
const handleAddReaction = (reactionHex: string) => {
if (!issueId) return;
issueDetailsStore.addIssueReaction(anchor, issueId, reactionHex);
};
const handleRemoveReaction = (reactionHex: string) => {
if (!issueId) return;
issueDetailsStore.removeIssueReaction(anchor, issueId, reactionHex);
};
const handleReactionClick = (reactionHex: string) => {
const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex);
if (userReaction) handleRemoveReaction(reactionHex);
else handleAddReaction(reactionHex);
};
// 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_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 (
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
);
});

View File

@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
import useIsInIframe from "@/hooks/use-is-in-iframe";
type TIssueVotes = {
anchor: string;
issueIdFromProps?: string;
size?: "md" | "sm";
};
export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
const { anchor, issueIdFromProps, size = "md" } = props;
// states
const [isSubmitting, setIsSubmitting] = useState(false);
// router
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
// query params
const peekId = searchParams.get("peekId") || undefined;
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// store hooks
const issueDetailsStore = useIssueDetails();
const { data: user } = useUser();
const isInIframe = useIsInIframe();
const issueId = issueIdFromProps ?? issueDetailsStore.peekId;
const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? [];
const allUpVotes = votes.filter((vote) => vote.vote === 1);
const allDownVotes = votes.filter((vote) => vote.vote === -1);
const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id);
const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id);
const handleVote = async (e: any, voteValue: 1 | -1) => {
if (!issueId) return;
setIsSubmitting(true);
const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue);
if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId);
else {
await issueDetailsStore.addIssueVote(anchor, issueId, {
vote: voteValue,
});
}
setIsSubmitting(false);
};
const VOTES_LIMIT = 1000;
// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
const votingDimensions = size === "sm" ? "px-1 h-6 min-w-9" : "px-2 h-7";
return (
<div className="flex items-center gap-2">
{/* upvote button 👇 */}
<Tooltip
tooltipContent={
<div>
{allUpVotes.length > 0 ? (
<>
{allUpVotes
.map((r) => r.actor_details?.display_name)
.splice(0, VOTES_LIMIT)
.join(", ")}
{allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"}
</>
) : (
"No upvotes yet"
)}
</div>
}
>
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
if (isInIframe) return;
if (user) handleVote(e, 1);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
className={cn(
"flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100",
votingDimensions,
{
"border-custom-primary-200 text-custom-primary-200": isUpVotedByUser,
"border-custom-border-300": !isUpVotedByUser,
"cursor-default": isInIframe,
}
)}
>
<span className="material-symbols-rounded !m-0 !p-0 text-base">arrow_upward_alt</span>
<span className="text-sm font-normal transition-opacity ease-in-out">{allUpVotes.length}</span>
</button>
</Tooltip>
{/* downvote button 👇 */}
<Tooltip
tooltipContent={
<div>
{allDownVotes.length > 0 ? (
<>
{allDownVotes
.map((r) => r.actor_details.display_name)
.splice(0, VOTES_LIMIT)
.join(", ")}
{allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"}
</>
) : (
"No downvotes yet"
)}
</div>
}
>
<button
type="button"
disabled={isSubmitting}
onClick={(e) => {
if (isInIframe) return;
if (user) handleVote(e, -1);
else router.push(`/?next_path=${pathName}?${queryParam}`);
}}
className={cn(
"flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100",
votingDimensions,
{
"border-red-600 text-red-600": isDownVotedByUser,
"border-custom-border-300": !isDownVotedByUser,
"cursor-default": isInIframe,
}
)}
>
<span className="material-symbols-rounded !m-0 !p-0 text-base">arrow_downward_alt</span>
<span className="text-sm font-normal transition-opacity ease-in-out">{allDownVotes.length}</span>
</button>
</Tooltip>
</div>
);
});