Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

124 linesCodeBlameRaw
1import { SmilePlus } from "lucide-react";
2import { useFetcher } from "react-router";
3
4import { REACTIONS, type ReactionContent, type ReactionGroup } from "@g1t/contracts";
5
6import { Button } from "./ui/button";
7import { Hint } from "./ui/hint";
8import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
9
10const EMOJI = Object.fromEntries(REACTIONS.map((one) => [one.content, one])) as Record<ReactionContent, (typeof REACTIONS)[number]>;
11
12/** "ana, bo and g1t" for who reacted; "You" for the viewer. */
13function who(users: string[], viewer: string | null) {
14 const names = users.map((name) => (name === viewer ? "You" : name));
15 if (names.length <= 1) return names.join("");
16 return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
17}
18
19/** `groups` with the viewer's reaction added or taken back, as the page shows it before the service answers. */
20function applied(groups: ReactionGroup[], viewer: string, content: ReactionContent, remove: boolean): ReactionGroup[] {
21 const next = groups
22 .map((group) => {
23 if (group.content !== content) return group;
24 const users = remove ? group.users.filter((name) => name !== viewer) : group.users.includes(viewer) ? group.users : [...group.users, viewer];
25 return { ...group, users, count: users.length };
26 })
27 .filter((group) => group.count > 0);
28 if (!remove && !next.some((group) => group.content === content)) next.push({ content, count: 1, users: [viewer] });
29 const order = REACTIONS.map((one) => one.content);
30 return next.sort((a, b) => order.indexOf(a.content) - order.indexOf(b.content));
31}
32
33/**
34 * How people (and g1t) reacted to a work item's or pull request's description
35 * (no `commentId`) or to one of its comments: a pill per emoji with how
36 * many, lit when the viewer is one of them, and who when hovered. Signed
37 * in, a pill adds or takes back the viewer's own and the smiley offers the
38 * rest; the page's action answers `react`. g1t's 👀 says it has seen what
39 * it was asked and is on it.
40 */
41export function Reactions({
42 reactions,
43 commentId,
44 viewer,
45 className = "",
46}: {
47 reactions?: ReactionGroup[];
48 commentId?: string;
49 /** The signed-in viewer's username, or null: then the pills only show. */
50 viewer: string | null;
51 className?: string;
52}) {
53 const fetcher = useFetcher();
54 let groups = reactions ?? [];
55 // What the viewer just did, shown at once.
56 const pending = fetcher.formData;
57 if (viewer && pending?.get("action") === "react") {
58 const content = String(pending.get("content")) as ReactionContent;
59 if (EMOJI[content]) groups = applied(groups, viewer, content, pending.get("remove") === "1");
60 }
61 if (groups.length === 0 && !viewer) return null;
62 const react = (content: ReactionContent, remove: boolean) =>
63 fetcher.submit({ action: "react", comment: commentId ?? "", content, remove: remove ? "1" : "" }, { method: "post", preventScrollReset: true });
64 return (
65 <div className={`flex flex-wrap items-center gap-1 ${className}`} aria-label="Reactions">
66 {groups.map((group) => {
67 const mine = viewer != null && group.users.includes(viewer);
68 const { emoji, label } = EMOJI[group.content];
69 return (
70 <Hint key={group.content} label={`${who(group.users, viewer)} reacted with ${label.toLowerCase()}`}>
71 <button
72 type="button"
73 aria-pressed={mine}
74 aria-label={`${label}, ${group.count} ${group.count === 1 ? "reaction" : "reactions"}${mine ? ", including yours" : ""}`}
75 disabled={!viewer}
76 onClick={() => react(group.content, mine)}
77 className={`flex h-6 items-center gap-1 rounded-full border px-1.5 text-xs font-medium tabular-nums transition-colors disabled:cursor-default ${
78 mine
79 ? "border-accent/60 bg-accent/15 text-accent hover:bg-accent/25"
80 : "border-line bg-surface text-muted hover:border-line-strong hover:bg-raised hover:text-fg"
81 }`}
82 >
83 <span aria-hidden className="text-sm leading-none">
84 {emoji}
85 </span>
86 {group.count}
87 </button>
88 </Hint>
89 );
90 })}
91 {viewer && (
92 <Popover>
93 <PopoverTrigger asChild>
94 <Button type="button" aria-label="Add a reaction" variant="outline" className="h-6 rounded-full bg-surface px-1.5 text-faint hover:bg-raised">
95 <SmilePlus size={14} />
96 </Button>
97 </PopoverTrigger>
98 <PopoverContent align="start" className="w-auto p-1.5">
99 <div className="flex gap-0.5">
100 {REACTIONS.map(({ content, emoji, label }) => {
101 const mine = groups.some((group) => group.content === content && group.users.includes(viewer));
102 return (
103 <Hint key={content} label={label}>
104 <Button
105 type="button"
106 variant="ghost"
107 size="icon-sm"
108 aria-label={label}
109 aria-pressed={mine}
110 className={`text-base ${mine ? "bg-accent/15" : ""}`}
111 onClick={() => react(content, mine)}
112 >
113 {emoji}
114 </Button>
115 </Hint>
116 );
117 })}
118 </div>
119 </PopoverContent>
120 </Popover>
121 )}
122 </div>
123 );
124}