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.

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