"use client"; import { ArrowUpIcon, CheckIcon, CopyIcon, TextQuoteIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type FormEvent, Fragment, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export interface QuoteAuthor { id: string; name: string; initials: string; avatarClassName?: string; } export interface QuotedSnippet { messageId: string; authorName: string; text: string; } export interface QuoteMessage { id: string; author: QuoteAuthor; time: string; text: string; quote?: QuotedSnippet; } export interface QuoteReplyProps { title?: string; messages: QuoteMessage[]; currentUser: QuoteAuthor; onSend?: (text: string, quote: QuotedSnippet | null) => void; className?: string; } interface Floating { messageId: string; text: string; x: number; y: number; below: boolean; } const spring = { type: "spring", stiffness: 480, damping: 38 } as const; export function QuoteReply({ title = "Onboarding review", messages: initial, currentUser, onSend, className }: QuoteReplyProps) { const reduce = useSafeReducedMotion(); const inputId = useId(); const cardRef = useRef(null); const listRef = useRef(null); const inputRef = useRef(null); const [messages, setMessages] = useState(initial); const [floating, setFloating] = useState(null); const [quote, setQuote] = useState(null); const [draft, setDraft] = useState(""); const [copied, setCopied] = useState(false); const [flash, setFlash] = useState<{ id: string; text: string; on: boolean } | null>(null); const flashTimer = useRef>(undefined); useEffect(() => () => clearTimeout(flashTimer.current), []); useEffect(() => { if (!floating) return; const onChange = () => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) setFloating(null); }; document.addEventListener("selectionchange", onChange); return () => document.removeEventListener("selectionchange", onChange); }, [floating]); const readSelection = () => { const sel = window.getSelection(); const card = cardRef.current; if (!sel || sel.isCollapsed || !sel.rangeCount || !card) return setFloating(null); const anchor = sel.anchorNode?.parentElement?.closest("[data-quote-source]"); const focus = sel.focusNode?.parentElement?.closest("[data-quote-source]"); const text = sel.toString().replace(/\s+/g, " ").trim(); if (!anchor || anchor !== focus || text.length < 2) return setFloating(null); const rect = sel.getRangeAt(0).getBoundingClientRect(); const box = card.getBoundingClientRect(); const below = rect.top - box.top < 56; setCopied(false); setFloating({ messageId: anchor.dataset.quoteSource ?? "", text: text.length > 280 ? `${text.slice(0, 277)}…` : text, x: Math.min(Math.max(rect.left + rect.width / 2 - box.left, 80), box.width - 80), y: below ? rect.bottom - box.top + 8 : rect.top - box.top - 8, below, }); }; const startQuote = (snippet: QuotedSnippet) => { setQuote(snippet); setFloating(null); window.getSelection()?.removeAllRanges(); requestAnimationFrame(() => inputRef.current?.focus()); }; const quoteFromFloating = () => { if (!floating) return; const msg = messages.find((m) => m.id === floating.messageId); if (msg) startQuote({ messageId: msg.id, authorName: msg.author.name, text: floating.text }); }; const copy = async () => { if (!floating) return; try { await navigator.clipboard.writeText(floating.text); setCopied(true); } catch { setCopied(false); } }; const jumpTo = (q: QuotedSnippet) => { const list = listRef.current; const el = list?.querySelector(`[data-message="${q.messageId}"]`); if (list && el) { const offset = el.getBoundingClientRect().top - list.getBoundingClientRect().top; list.scrollTo({ top: list.scrollTop + offset - (list.clientHeight - el.offsetHeight) / 2, behavior: reduce ? "auto" : "smooth" }); } setFlash({ id: q.messageId, text: q.text, on: true }); clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => { setFlash((f) => (f ? { ...f, on: false } : f)); flashTimer.current = setTimeout(() => setFlash(null), 900); }, 700); }; const send = (e?: FormEvent) => { e?.preventDefault(); const text = draft.trim(); if (!text) return; onSend?.(text, quote); setMessages((m) => [...m, { id: `m-${Date.now()}`, author: currentUser, time: "now", text, quote: quote ?? undefined }]); setDraft(""); setQuote(null); requestAnimationFrame(() => { const list = listRef.current; list?.scrollTo({ top: list.scrollHeight, behavior: reduce ? "auto" : "smooth" }); }); }; return (

{title}

Select text to quote
e.shiftKey && readSelection()} onMouseUp={() => requestAnimationFrame(readSelection)} onScroll={() => floating && setFloating(null)} ref={listRef} > {messages.map((m) => { const mine = m.author.id === currentUser.id; return ( {m.author.initials}
{mine ? "You" : m.author.name} {m.time}
{m.quote && ( )}

); })}
{floating && ( e.preventDefault()} role="toolbar" style={{ left: floating.x, top: floating.y, translate: floating.below ? "-50% 0" : "-50% -100%", transformOrigin: floating.below ? "top center" : "bottom center", }} transition={{ duration: 0.14, ease: [0.23, 1, 0.32, 1] }} > )}
{quote && (
Replying to {quote.authorName.split(" ")[0]} {quote.text}
)}