"use client"; import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { createPortal } from "react-dom"; import { EMOJI_GROUPS } from "./emojis"; import { messageActionIcons, type MessageActionIcons } from "./icons"; import styles from "./MessageActions.module.css"; const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; type CueApi = { aim: (anchor: HTMLElement, label: string) => void; hide: () => void; }; const CueContext = createContext(null); function Cue({ label, children, quiet = false, }: { label: string; children: ReactNode; quiet?: boolean; }) { const cue = useContext(CueContext); const ref = useRef(null); useLayoutEffect(() => { const node = ref.current; if (!node || !cue) return; const hot = node.matches(":hover") || node.contains(document.activeElement); if (!hot) return; if (quiet) cue.hide(); else cue.aim(node, label); }, [cue, label, quiet]); return ( { if (quiet) return; cue?.aim(event.currentTarget, label); }} onFocus={(event) => { if (quiet) return; cue?.aim(event.currentTarget, label); }} > {children} ); } function RowCues({ children }: { children: ReactNode }) { const tipId = useId(); const bubbleRef = useRef(null); const gaugeRef = useRef(null); const anchorRef = useRef(null); const labelRef = useRef(""); const openRef = useRef(false); const describedRef = useRef(null); const [mounted, setMounted] = useState(false); const [open, setOpen] = useState(false); const [label, setLabel] = useState(""); const [tipTheme, setTipTheme] = useState<"light" | "dark">("light"); useEffect(() => setMounted(true), []); const write = (bubble: HTMLDivElement, x: number, y: number, width: number) => { bubble.style.setProperty("--cue-x", `${x}px`); bubble.style.setProperty("--cue-y", `${y}px`); bubble.style.setProperty("--cue-w", `${width}px`); }; const measure = (anchor: HTMLElement, next: string) => { const bubble = bubbleRef.current; const gauge = gaugeRef.current; if (!bubble || !gauge) return null; gauge.textContent = next; const host = (anchor.querySelector("button, time") as HTMLElement | null) ?? anchor; const rect = host.getBoundingClientRect(); const width = Math.ceil(gauge.offsetWidth + 10); const height = Math.ceil(gauge.offsetHeight + 8); return { host, x: Math.round(rect.left + rect.width / 2 - width / 2), y: Math.round(rect.top - height - 6), width, }; }; const place = useCallback((anchor: HTMLElement, next: string, reveal: boolean) => { const bubble = bubbleRef.current; const spot = measure(anchor, next); if (!bubble || !spot) return; const arriving = reveal && !openRef.current; if (arriving) { bubble.dataset.hold = "true"; write(bubble, spot.x, spot.y, spot.width); void bubble.offsetWidth; delete bubble.dataset.hold; openRef.current = true; setOpen(true); } else if (openRef.current) { write(bubble, spot.x, spot.y, spot.width); } if (describedRef.current && describedRef.current !== spot.host) { describedRef.current.removeAttribute("aria-describedby"); } spot.host.setAttribute("aria-describedby", tipId); describedRef.current = spot.host; const from = anchor.closest("[data-theme]")?.getAttribute("data-theme"); setTipTheme(from === "dark" ? "dark" : "light"); setLabel((current) => (current === next ? current : next)); }, [tipId]); const aim = useCallback((anchor: HTMLElement, next: string) => { anchorRef.current = anchor; labelRef.current = next; place(anchor, next, true); }, [place]); const hide = useCallback(() => { openRef.current = false; anchorRef.current = null; describedRef.current?.removeAttribute("aria-describedby"); describedRef.current = null; setOpen(false); }, []); useIsoLayoutEffect(() => { if (!open) return; const follow = () => { const anchor = anchorRef.current; const bubble = bubbleRef.current; if (!anchor || !bubble || !openRef.current) return; const spot = measure(anchor, labelRef.current); if (!spot) return; bubble.dataset.hold = "true"; write(bubble, spot.x, spot.y, spot.width); void bubble.offsetWidth; delete bubble.dataset.hold; }; window.addEventListener("scroll", follow, true); window.addEventListener("resize", follow); return () => { window.removeEventListener("scroll", follow, true); window.removeEventListener("resize", follow); }; }, [open]); const api = useMemo(() => ({ aim, hide }), [aim, hide]); return ( {children} {mounted ? createPortal( <> ); } function CueRoot({ rootRef, children, }: { rootRef: RefObject; children: ReactNode; }) { const cue = useContext(CueContext); return (
cue?.hide()} onBlur={(event) => { const next = event.relatedTarget; if (next instanceof Node && event.currentTarget.contains(next)) return; cue?.hide(); }} > {children}
); } const REACTIONS = ["👍", "❤️", "😄", "🎉", "🤔", "👀"] as const; type Vote = "up" | "down" | null; const ROLL_MS = 400; function elapsedMinutes(date: Date, now: number) { return Math.max(0, Math.round((now - date.getTime()) / 60000)); } function formatAgo(date: Date, now = Date.now()) { const minutes = elapsedMinutes(date, now); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.round(hours / 24); return `${days}d ago`; } function MinuteRoll({ value }: { value: string }) { const prevRef = useRef(value); const [oldVal, setOldVal] = useState(value); const [newVal, setNewVal] = useState(value); const [rolling, setRolling] = useState(false); const [shifted, setShifted] = useState(false); const [dir, setDir] = useState<"up" | "down">("up"); useEffect(() => { if (prevRef.current === value) return; const from = prevRef.current; prevRef.current = value; const fromN = parseInt(from, 10); const toN = parseInt(value, 10); setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up"); setOldVal(from); setNewVal(value); setRolling(true); setShifted(false); let raf2 = 0; const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setShifted(true)); }); const done = window.setTimeout(() => { setRolling(false); setOldVal(value); setShifted(false); }, ROLL_MS); return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); window.clearTimeout(done); }; }, [value]); const chars = rolling ? newVal : oldVal; return ( {Array.from({ length: chars.length }, (_, i) => { const previous = oldVal[i] ?? ""; const next = chars[i] ?? ""; if (!rolling || previous === next) { return ( {next} ); } const top = dir === "down" ? next : previous; const bottom = dir === "down" ? previous : next; return ( {top} {bottom} ); })} ); } function formatAbsolute(date: Date) { return date.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }); } export function MessageActions({ text = "Thanks, that answered it.", sentAt, onCopy, onVote, onReact, onReply, onFork, icons, }: { text?: string; sentAt?: Date; onCopy?: (text: string) => void; onVote?: (vote: Vote) => void; onReact?: (emoji: string | null) => void; onReply?: () => void; onFork?: () => void; icons?: Partial; }) { const [vote, setVote] = useState(null); const [copied, setCopied] = useState(false); const [panel, setPanel] = useState<"react" | "more" | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [query, setQuery] = useState(""); const [reaction, setReaction] = useState(null); const [extra, setExtra] = useState(null); const [extraOut, setExtraOut] = useState(false); const [upN, setUpN] = useState(0); const [downN, setDownN] = useState(0); const [smileN, setSmileN] = useState(0); const [replyN, setReplyN] = useState(0); const icon = { ...messageActionIcons, ...icons }; const Good = icon.good; const Bad = icon.bad; const Copy = icon.copy; const Copied = icon.copied; const Smile = icon.smile; const Reply = icon.reply; const More = icon.more; const Aloud = icon.aloud; const Fork = icon.fork; const Report = icon.report; const Search = icon.search; const AddEmoji = icon.addEmoji; const rootRef = useRef(null); const popRef = useRef(null); const pickerRef = useRef(null); const searchRef = useRef(null); const popId = useId(); const menuId = useId(); const [postedAt] = useState(() => sentAt ?? new Date(Date.now() - 3 * 60 * 1000)); const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(id); }, []); useEffect(() => { if (!copied) return; const timer = window.setTimeout(() => setCopied(false), 900); return () => window.clearTimeout(timer); }, [copied]); useEffect(() => { if (!panel) return; const onPointer = (event: PointerEvent) => { const target = event.target as Node; if (rootRef.current?.contains(target) || pickerRef.current?.contains(target)) return; setPanel(null); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setPanel(null); }; document.addEventListener("pointerdown", onPointer); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("pointerdown", onPointer); document.removeEventListener("keydown", onKey); }; }, [panel]); useEffect(() => { if (panel === "react") return; setPickerOpen(false); setQuery(""); }, [panel]); useEffect(() => { if (panel === "react" || !extraOut) return; setExtra(null); setExtraOut(false); }, [panel, extraOut]); useEffect(() => { if (!pickerOpen) return; searchRef.current?.focus(); }, [pickerOpen]); const choose = (next: Vote) => { const value = vote === next ? null : next; setVote(value); onVote?.(value); if (next === "up") setUpN((n) => n + 1); if (next === "down") setDownN((n) => n + 1); }; const copy = async () => { try { await navigator.clipboard.writeText(text); } catch { /* preview still shows the drawn check */ } setCopied(true); onCopy?.(text); }; const react = (emoji: string) => { const value = reaction === emoji ? null : emoji; const preset = (REACTIONS as readonly string[]).includes(emoji); const fromRow = panel === "react" && !pickerOpen; setReaction(value); onReact?.(value); setPickerOpen(false); setQuery(""); if (!preset && value) { setExtra(emoji); setExtraOut(false); setPanel(null); return; } const leaveExtra = extra !== null && (value === null && emoji === extra || (value !== null && value !== extra)); if (leaveExtra && fromRow && emoji === extra) { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setExtra(null); setExtraOut(false); } else { setExtraOut(true); } return; } if (leaveExtra) { setExtra(null); setExtraOut(false); } setPanel(null); }; const emojiGroups = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return EMOJI_GROUPS; return EMOJI_GROUPS.map((group) => ({ label: group.label, items: group.items.filter(([, keywords]) => keywords.includes(q)), })).filter((group) => group.items.length > 0); }, [query]); const placePicker = useCallback(() => { const anchor = rootRef.current; const picker = pickerRef.current; if (!anchor || !picker) return; const rect = anchor.getBoundingClientRect(); const width = picker.offsetWidth; const height = picker.offsetHeight; let left = rect.left; let top = rect.top - height - 8; if (top < 8) top = rect.bottom + 8; left = Math.min(Math.max(8, left), window.innerWidth - width - 8); picker.style.left = `${Math.round(left)}px`; picker.style.top = `${Math.round(top)}px`; }, []); useIsoLayoutEffect(() => { if (!pickerOpen) return; placePicker(); window.addEventListener("scroll", placePicker, true); window.addEventListener("resize", placePicker); return () => { window.removeEventListener("scroll", placePicker, true); window.removeEventListener("resize", placePicker); }; }, [pickerOpen, query, placePicker]); const reply = () => { setReplyN((n) => n + 1); setPanel(null); onReply?.(); }; const more = (action: "aloud" | "fork" | "report") => { if (action === "fork") onFork?.(); setPanel(null); }; return ( {panel === "more" ? (