| 1 | import 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. */ |
| 4 | export 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 | |
| 15 | const 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. */ |
| 27 | const 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 | |
| 38 | export function pullRequestReactionEmoji(content: PullRequestReactionContent): string { |
| 39 | return REACTION_EMOJI[content]; |
| 40 | } |
| 41 | |
| 42 | export 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. */ |
| 47 | const NAMED_ACTOR_LIMIT = 3; |
| 48 | |
| 49 | function 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. */ |
| 56 | function 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 | */ |
| 73 | export 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 | */ |
| 88 | export 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 | } |