1import { useAtomCommand } from "~/state/use-atom-command";
2import type {
3 EnvironmentId,
4 PullRequestReaction,
5 PullRequestReactionContent,
6 PullRequestRef,
7} from "@t3tools/contracts";
8import { SmilePlusIcon } from "lucide-react";
9import { useState } from "react";
10
11import { cn } from "~/lib/utils";
12import { pullRequestEnvironment } from "~/state/pullRequests";
13
14import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
15import { toastManager } from "../ui/toast";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import {
18 applyPendingPullRequestReactions,
19 PULL_REQUEST_REACTION_ORDER,
20 pullRequestReactionEmoji,
21 pullRequestReactionName,
22 pullRequestReactionTooltip,
23} from "./pullRequestReactions.logic";
24
25const PILL_CLASS =
26 "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-inset focus-visible:ring-ring";
27
28const EMPTY_PENDING: ReadonlyMap<PullRequestReactionContent, boolean> = new Map();
29
30/** What the host last said, so a press in flight is forgotten the moment the real counts land. */
31function reactionsSignature(reactions: ReadonlyArray<PullRequestReaction>): string {
32 return reactions
33 .map((reaction) => `${reaction.content}:${reaction.count}:${reaction.viewerHasReacted ? 1 : 0}`)
34 .join(" ");
35}
36
37/** Reaction counts and an always-visible picker, routed to the supplied host subject. */
38export function PullRequestReactionBar({
39 reactions,
40 canReact,
41 subjectId,
42 environmentId,
43 reference,
44 onRefresh,
45 className,
46}: {
47 readonly reactions: ReadonlyArray<PullRequestReaction>;
48 readonly canReact: boolean;
49 /** Absent reacts to the change request itself, which is where its description's reactions live. */
50 readonly subjectId?: string | undefined;
51 readonly environmentId: EnvironmentId;
52 readonly reference: PullRequestRef;
53 readonly onRefresh: () => void;
54 readonly className?: string | undefined;
55}) {
56 const [pickerOpen, setPickerOpen] = useState(false);
57 const [pending, setPending] = useState<{
58 readonly signature: string;
59 readonly values: ReadonlyMap<PullRequestReactionContent, boolean>;
60 }>({ signature: "", values: EMPTY_PENDING });
61 const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, {
62 reportFailure: false,
63 });
64
65 const signature = reactionsSignature(reactions);
66 const values = pending.signature === signature ? pending.values : EMPTY_PENDING;
67 const shown = applyPendingPullRequestReactions(reactions, values);
68
69 const toggle = async (content: PullRequestReactionContent, reacted: boolean) => {
70 setPending({ signature, values: new Map([...values, [content, reacted]]) });
71 const result = await setReaction({
72 environmentId,
73 input: {
74 ...reference,
75 ...(subjectId === undefined ? {} : { subjectId }),
76 content,
77 reacted,
78 },
79 });
80 if (result._tag === "Failure") {
81 setPending((current) => {
82 const next = new Map(current.values);
83 next.delete(content);
84 return { signature: current.signature, values: next };
85 });
86 toastManager.add({ type: "error", title: "The reaction could not be saved" });
87 return;
88 }
89 onRefresh();
90 };
91
92 if (shown.length === 0 && !canReact) return null;
93
94 return (
95 <div className={cn("flex min-w-0 max-w-full flex-wrap items-center gap-1", className)}>
96 {shown.map((reaction) => (
97 <Tooltip key={reaction.content}>
98 <TooltipTrigger
99 render={
100 <button
101 type="button"
102 aria-pressed={reaction.viewerHasReacted}
103 aria-label={`${pullRequestReactionName(reaction.content)}, ${reaction.count}`}
104 disabled={!canReact}
105 className={cn(
106 PILL_CLASS,
107 reaction.viewerHasReacted
108 ? "border-primary/60 bg-primary/10 text-foreground"
109 : "border-border/70 bg-muted/40 text-muted-foreground",
110 canReact ? "hover:border-primary/60" : "cursor-default",
111 )}
112 onClick={() => void toggle(reaction.content, !reaction.viewerHasReacted)}
113 />
114 }
115 >
116 <span aria-hidden>{pullRequestReactionEmoji(reaction.content)}</span>
117 <span className="tabular-nums">{reaction.count}</span>
118 </TooltipTrigger>
119 <TooltipPopup side="top">{pullRequestReactionTooltip(reaction)}</TooltipPopup>
120 </Tooltip>
121 ))}
122
123 {canReact ? (
124 <Popover open={pickerOpen} onOpenChange={setPickerOpen}>
125 <PopoverTrigger
126 render={
127 <button
128 type="button"
129 aria-label="Add a reaction"
130 className={cn(
131 PILL_CLASS,
132 "border-border/70 px-1.5 text-muted-foreground hover:border-primary/60 hover:text-foreground",
133 )}
134 />
135 }
136 >
137 <SmilePlusIcon aria-hidden className="size-3.5" />
138 </PopoverTrigger>
139 <PopoverPopup align="start" side="top" padding="compact">
140 <div className="flex items-center gap-0.5">
141 {PULL_REQUEST_REACTION_ORDER.map((content) => {
142 const reacted =
143 shown.find((reaction) => reaction.content === content)?.viewerHasReacted ?? false;
144 return (
145 <button
146 key={content}
147 type="button"
148 aria-pressed={reacted}
149 aria-label={pullRequestReactionName(content)}
150 className={cn(
151 "flex size-7 items-center justify-center rounded-md text-base outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
152 reacted && "bg-primary/10",
153 )}
154 onClick={() => {
155 setPickerOpen(false);
156 void toggle(content, !reacted);
157 }}
158 >
159 <span aria-hidden>{pullRequestReactionEmoji(content)}</span>
160 </button>
161 );
162 })}
163 </div>
164 </PopoverPopup>
165 </Popover>
166 ) : null}
167 </div>
168 );
169}