import { useState } from "react"; import type { Event } from "../../types"; import { lookupCustomEmoji } from "../../lib/emoji"; import { cn } from "../../lib/utils"; import { Popover, PopoverPopup, PopoverPortal, PopoverPositioner, PopoverTrigger, } from "../../ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip"; /** Identifier shape used to highlight chips and own reactions. */ type CurrentUser = Event.Viewer; type ReactionsProps = { /** Flat reactions array from the message event. */ reactions: Event.Message.Reaction[]; /** Which edge stays fixed as stacked chips expand. */ align?: "left" | "right"; /** Current user — chips that include this user render with a highlighted style. */ currentUser?: CurrentUser; /** Called when a chip is clicked. */ onReact?: (emoji: string) => void; /** Reports overflow popover state to the parent so hover affordances stay mounted. */ onOpenChange?: (open: boolean) => void; className?: string; }; type Chip = { emoji: string; count: number; mine: boolean; users: Event.Message.Reaction[] }; type Entry = { count: number; mine: boolean; seen: Set; users: Event.Message.Reaction[] }; /** Custom-emoji shortcode pattern: `:id:` (mirrors what the emoji-mart picker emits). */ const SHORTCODE = /^:([\w-]+):$/; const LIMIT = 8; /** Render a single emoji string — image for custom shortcodes, text for unicode. */ function EmojiGlyph({ emoji }: { emoji: string }) { let m = SHORTCODE.exec(emoji); if (m) { let custom = lookupCustomEmoji(m[1]!); if (custom) { return {custom.alt}; } } return {emoji}; } /** Match verified author IDs exclusively when the native caller provides them. */ function isMine(from: Event.Message.Reaction["from"], me?: CurrentUser): boolean { if (!me) return false; if (me.own) return me.own.includes(from); if (from === me.login) return true; return me.id !== undefined && from === String(me.id); } /** Group reactions by emoji into ordered chips, flagging chips the current user is part of. */ function group(reactions: Event.Message.Reaction[], me?: CurrentUser): Chip[] { let map = new Map(); for (let r of reactions) { let entry = map.get(r.emoji); let mine = isMine(r.from, me); let from = String(r.from); if (entry) { entry.count++; if (mine) entry.mine = true; if (!entry.seen.has(from)) { entry.seen.add(from); entry.users.push(r); } } else { map.set(r.emoji, { count: 1, mine, seen: new Set([from]), users: [r] }); } } return Array.from(map, ([emoji, { count, mine, users }]) => ({ emoji, count, mine, users })); } /** Format the tooltip text listing who reacted with an emoji. */ function reactors(users: Event.Message.Reaction[], me?: CurrentUser): string { let names = users.map(reaction => name(reaction, me)); let mine = names.indexOf("You"); if (mine > 0) { names.splice(mine, 1); names.unshift("You"); } if (names.length === 1) return names[0]!; if (names.length === 2) return `${names[0]} and ${names[1]}`; if (names.length === 3) return `${names[0]}, ${names[1]} and ${names[2]}`; return `${names[0]}, ${names[1]} and ${names.length - 2} others`; } function name(reaction: Event.Message.Reaction, me?: CurrentUser): string { return isMine(reaction.from, me) ? "You" : reaction.display || String(reaction.from); } /** Static class strings indexed by tier — Tailwind needs literal class names. */ const SIZE = { wide: "w-14 px-2", mid: "w-12 px-2", pair: "w-9 px-1.5", single: "size-5.5 p-0", } as const; const STACK = { right: { wide: "-mr-10", mid: "-mr-8", pair: "-mr-5", single: "-mr-2.5" }, left: { wide: "-ml-10", mid: "-ml-8", pair: "-ml-5", single: "-ml-2.5" }, } as const; const SLIDE = { right: "group-hover/reactions:mr-0.5 group-data-[popup-open]/reactions:mr-0.5", left: "group-hover/reactions:ml-0.5 group-data-[popup-open]/reactions:ml-0.5", } as const; function tier(count: number): keyof typeof SIZE { if (count > 99) return "wide"; if (count > 9) return "mid"; if (count > 1) return "pair"; return "single"; } function size(count: number): string { return SIZE[tier(count)]; } function stack(count: number, right: boolean): string { let t = tier(count); return right ? `${STACK.right[t]} ${SLIDE.right}` : `${STACK.left[t]} ${SLIDE.left}`; } /** * Stacked reaction pills — chips overlap by default and slide apart on hover. * Clicking a chip toggles the current user's reaction for that emoji. */ function Reactions( { reactions, align = "left", currentUser, onReact, onOpenChange, className }: ReactionsProps, ) { let [more, setMore] = useState(false); function handleMoreChange(o: boolean) { setMore(o); onOpenChange?.(o); } let chips = group(reactions, currentUser); let shown = chips.slice(0, LIMIT); let hidden = chips.slice(LIMIT); let total = hidden.reduce((sum, chip) => sum + chip.count, 0); let hiddenEmoji = new Set(hidden.map(chip => chip.emoji)); let extra = hidden.length ? reactions.filter(reaction => hiddenEmoji.has(reaction.emoji)) : []; let right = align === "right"; if (!chips.length) return null; return (
{shown.map(({ emoji, count, mine, users }, i) => ( onReact?.(emoji)} className={cn( "relative h-5.5 shrink-0 rounded-full text-xs leading-none flex items-center justify-center gap-1 transition-[margin-left,margin-right,background-color] duration-200", i > 0 && stack(shown[i - 1]!.count, right), size(count), mine ? "border border-[oklch(from_var(--success)_l_calc(c_*_0.5)_h_/_0.6)] bg-[oklch(from_color-mix(in_oklch,var(--success)_22%,var(--background))_calc(l_+_0.03)_c_h)] hover:bg-[oklch(from_color-mix(in_oklch,var(--success)_30%,var(--background))_calc(l_+_0.03)_c_h)]" : "border border-border bg-muted hover:bg-secondary", )} /> } > {count > 1 && ( {count} )} {reactors(users, currentUser)} ))} {hidden.length > 0 && ( } > +{total}
{extra.map(reaction => ( ))}
)}
); } export { Reactions }; export type { ReactionsProps };