import type { EnvironmentId, PullRequestReaction, PullRequestReactionContent, PullRequestRef, } from "@t3tools/contracts"; import { SmilePlusIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "~/lib/utils"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { applyPendingPullRequestReactions, PULL_REQUEST_REACTION_ORDER, pullRequestReactionEmoji, pullRequestReactionName, pullRequestReactionTooltip, } from "./pullRequestReactions.logic"; const PILL_CLASS = "inline-flex h-6 shrink-0 items-center gap-1 rounded-full border px-2 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"; const EMPTY_PENDING: ReadonlyMap = new Map(); /** What the host last said, so a press in flight is forgotten the moment the real counts land. */ function reactionsSignature(reactions: ReadonlyArray): string { return reactions .map((reaction) => `${reaction.content}:${reaction.count}:${reaction.viewerHasReacted ? 1 : 0}`) .join(" "); } /** * The reaction pills under a remark, and the picker that adds one. The same bar serves the * description, a conversation comment and a review thread's comments: what differs between them * is only which subject the host is told about. * * The add button is revealed by hovering the remark it belongs to, the way GitHub's is, so the * parent must carry `group`. It stays put once there is something to press it beside, while the * picker is open, and whenever it is focused — a control only a mouse can find is no control. */ export function PullRequestReactionBar({ reactions, canReact, subjectId, environmentId, reference, onRefresh, className, }: { readonly reactions: ReadonlyArray; readonly canReact: boolean; /** Absent reacts to the change request itself, which is where its description's reactions live. */ readonly subjectId?: string | undefined; readonly environmentId: EnvironmentId; readonly reference: PullRequestRef; readonly onRefresh: () => void; readonly className?: string | undefined; }) { const [pickerOpen, setPickerOpen] = useState(false); const [pending, setPending] = useState<{ readonly signature: string; readonly values: ReadonlyMap; }>({ signature: "", values: EMPTY_PENDING }); const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false }); const signature = reactionsSignature(reactions); const values = pending.signature === signature ? pending.values : EMPTY_PENDING; const shown = applyPendingPullRequestReactions(reactions, values); const toggle = async (content: PullRequestReactionContent, reacted: boolean) => { setPending({ signature, values: new Map([...values, [content, reacted]]) }); const result = await setReaction({ environmentId, input: { ...reference, ...(subjectId === undefined ? {} : { subjectId }), content, reacted, }, }); if (result._tag === "Failure") { setPending((current) => { const next = new Map(current.values); next.delete(content); return { signature: current.signature, values: next }; }); toastManager.add({ type: "error", title: "The reaction could not be saved" }); return; } onRefresh(); }; if (shown.length === 0 && !canReact) return null; return (
{shown.map((reaction) => ( void toggle(reaction.content, !reaction.viewerHasReacted)} /> } > {pullRequestReactionEmoji(reaction.content)} {reaction.count} {pullRequestReactionTooltip(reaction)} ))} {canReact ? ( } >
{PULL_REQUEST_REACTION_ORDER.map((content) => { const reacted = shown.find((reaction) => reaction.content === content)?.viewerHasReacted ?? false; return ( ); })}
) : null}
); }