1import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts";
2
3/** The picker's order, which is GitHub's: the two verdicts first, then the rest as it lists them. */
4export const PULL_REQUEST_REACTION_ORDER: ReadonlyArray<PullRequestReactionContent> = [
5 "thumbs-up",
6 "thumbs-down",
7 "laugh",
8 "hooray",
9 "confused",
10 "heart",
11 "rocket",
12 "eyes",
13];
14
15const REACTION_EMOJI: Record<PullRequestReactionContent, string> = {
16 "thumbs-up": "👍",
17 "thumbs-down": "👎",
18 laugh: "😄",
19 hooray: "🎉",
20 confused: "😕",
21 heart: "❤️",
22 rocket: "🚀",
23 eyes: "👀",
24};
25
26/** The spoken names GitHub uses in its own hover text, which is what a screen reader reads out. */
27const REACTION_NAME: Record<PullRequestReactionContent, string> = {
28 "thumbs-up": "thumbs up",
29 "thumbs-down": "thumbs down",
30 laugh: "laugh",
31 hooray: "hooray",
32 confused: "confused",
33 heart: "heart",
34 rocket: "rocket",
35 eyes: "eyes",
36};
37
38export function pullRequestReactionEmoji(content: PullRequestReactionContent): string {
39 return REACTION_EMOJI[content];
40}
41
42export function pullRequestReactionName(content: PullRequestReactionContent): string {
43 return REACTION_NAME[content];
44}
45
46/** Past three names the sentence stops being readable and starts being a list. */
47const NAMED_ACTOR_LIMIT = 3;
48
49function joinNames(parts: ReadonlyArray<string>): string {
50 if (parts.length <= 1) return parts[0] ?? "";
51 if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
52 return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
53}
54
55/** "others" only alongside somebody named; on its own a count is people, not other people. */
56function countRemainder(count: number, named: boolean): string {
57 if (named) return `${count} ${count === 1 ? "other" : "others"}`;
58 return `${count} ${count === 1 ? "person" : "people"}`;
59}
60
61/**
62 * Who reacted, in GitHub's sentence. The viewer reads as "You" and comes first, because that is
63 * the name they are looking for; a compliant host already leaves the viewer's own login out of
64 * `actors` when they have reacted, leaving room for "You" without ever exceeding `count`. This
65 * function has no viewer login to match against `actors`, so it cannot tell a non-compliant host
66 * apart by name — but such a host gives itself away by leaving no room: `actors` alone already
67 * accounts for everyone `count` claims. Then the viewer is already in `actors` under their own
68 * login, and naming them "You" too would either invent a person or hide a real one, so `actors`
69 * is named as given instead. The cap still guards further: the sentence never names more people
70 * than `count` claims, and the remainder is counted rather than named — a host reports fewer
71 * logins than it counts, so `count` is the only trustworthy total.
72 */
73export function pullRequestReactionTooltip(reaction: PullRequestReaction): string {
74 const viewerHasRoom = reaction.actors.length < reaction.count;
75 const names =
76 reaction.viewerHasReacted && viewerHasRoom ? ["You", ...reaction.actors] : [...reaction.actors];
77 const shown = names.slice(0, Math.min(NAMED_ACTOR_LIMIT, reaction.count));
78 const others = Math.max(0, reaction.count - shown.length);
79 const parts = [...shown, ...(others > 0 ? [countRemainder(others, shown.length > 0)] : [])];
80 return `${joinNames(parts)} reacted with ${pullRequestReactionName(reaction.content)} emoji`;
81}
82
83/**
84 * The list as it should be drawn while a reaction is still in flight. Reading a change request
85 * back from the host takes seconds, and a pill that does not move until then reads as a press
86 * that did nothing.
87 */
88export function applyPendingPullRequestReactions(
89 reactions: ReadonlyArray<PullRequestReaction>,
90 pending: ReadonlyMap<PullRequestReactionContent, boolean>,
91): ReadonlyArray<PullRequestReaction> {
92 if (pending.size === 0) return reactions;
93 const byContent = new Map(reactions.map((reaction) => [reaction.content, reaction] as const));
94 for (const [content, reacted] of pending) {
95 const current = byContent.get(content);
96 if (current === undefined) {
97 if (reacted) {
98 byContent.set(content, { content, count: 1, actors: [], viewerHasReacted: true });
99 }
100 continue;
101 }
102 if (current.viewerHasReacted === reacted) continue;
103 const count = current.count + (reacted ? 1 : -1);
104 if (count <= 0) byContent.delete(content);
105 else byContent.set(content, { ...current, count, viewerHasReacted: reacted });
106 }
107 return PULL_REQUEST_REACTION_ORDER.flatMap((content) => byContent.get(content) ?? []);
108}