"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { GraphNode } from "@/lib/types"; import { buildDescriptionCopyText, formatRelativeTime } from "@/lib/utils"; import { speakWithElevenLabs, speakSelection, stopTts, pauseTts, resumeTts, stripMarkdown, setTtsPlaybackRate, type TtsState, } from "@/lib/tts"; import { hasApiKey } from "@/lib/settings"; import { useIsMobile } from "@/hooks/useIsMobile"; import { HeartIcon } from "@/components/HeartIcon"; import type { BeadsComment } from "@/hooks/useBeadsComments"; // Issue 4 (beads-map-r2n3): Count all comments recursively (root + all replies) function countAllComments(comments: BeadsComment[]): number { return comments.reduce((sum, c) => sum + 1 + countAllComments(c.replies || []), 0); } const SPEED_PRESETS = [ { label: "Normal", value: 1 }, { label: "1.25x", value: 1.25 }, { label: "1.5x", value: 1.5 }, { label: "1.75x", value: 1.75 }, { label: "2x", value: 2 }, ]; // ============================================================================ // ModalCommentItem — inline comment renderer for DescriptionModal // ============================================================================ function ModalCommentItem({ comment, currentDid, isAuthenticated, onDelete, onLike, onStartReply, replyingToUri, replyText, onReplyTextChange, onSubmitReply, onCancelReply, isSubmittingReply, depth, }: { comment: BeadsComment; currentDid?: string; isAuthenticated?: boolean; onDelete?: (comment: { rkey: string }) => Promise; onLike?: (comment: BeadsComment) => Promise; onStartReply: (comment: BeadsComment) => void; replyingToUri: string | null; replyText: string; onReplyTextChange: (text: string) => void; onSubmitReply: () => void; onCancelReply: () => void; isSubmittingReply: boolean; depth: number; }) { const [deleting, setDeleting] = useState(false); const [liking, setLiking] = useState(false); const isOwn = currentDid && currentDid === comment.did; const hasLiked = currentDid ? comment.likes.some((l) => l.did === currentDid) : false; const isReplyingToThis = replyingToUri === comment.uri; const handleDelete = async () => { if (!onDelete || deleting) return; setDeleting(true); try { await onDelete({ rkey: comment.rkey }); } catch (err) { console.error("Failed to delete comment:", err); } finally { setDeleting(false); } }; const handleLike = async () => { if (!onLike || liking) return; setLiking(true); try { await onLike(comment); } catch (err) { console.error("Failed to toggle like:", err); } finally { setLiking(false); } }; return (
0 ? "ml-4 pl-3 border-l border-zinc-100 dark:border-zinc-800" : ""}`}>
{/* Header: avatar + handle + time */}
{comment.avatar ? ( ) : (
{(comment.handle || comment.did).charAt(0).toUpperCase()}
)}
{comment.displayName || comment.handle || comment.did.slice(0, 16) + "..."} {formatRelativeTime(comment.createdAt)}
{/* Comment text */}

{comment.text}

{/* Actions: like, reply, delete */}
{isAuthenticated && ( )} {isOwn && onDelete && ( )}
{/* Inline reply form */} {isReplyingToThis && (
Replying to {comment.displayName || comment.handle}
onReplyTextChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSubmitReply(); } if (e.key === "Escape") { e.stopPropagation(); onCancelReply(); } }} placeholder="Write a reply..." disabled={isSubmittingReply} autoFocus className="flex-1 px-2 py-1 text-xs bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded placeholder-zinc-400 focus:outline-none focus:border-emerald-400 disabled:opacity-50" />
)} {/* Nested replies */} {comment.replies.length > 0 && (
{comment.replies.map((reply) => ( ))}
)}
); } interface DescriptionModalProps { node: GraphNode; onClose: () => void; repoUrl?: string; onOpenSettings?: () => void; onNavigate?: (direction: "prev" | "next") => void; // NEW navigationLabel?: string; // NEW — e.g. "2 of 5" to show position isAuthenticated?: boolean; session?: { did: string; handle: string; avatar?: string } | null; comments?: BeadsComment[]; onPostComment?: (nodeId: string, text: string) => Promise; onReplyComment?: (parentComment: BeadsComment, text: string) => Promise; onDeleteComment?: (comment: { rkey: string }) => Promise; onLikeComment?: (comment: BeadsComment) => Promise; } export function DescriptionModal({ node, onClose, repoUrl, onOpenSettings, onNavigate, navigationLabel, isAuthenticated, session, comments, onPostComment, onReplyComment, onDeleteComment, onLikeComment, }: DescriptionModalProps) { const [copied, setCopied] = useState(false); const [sharedLink, setSharedLink] = useState(false); const [ttsState, setTtsState] = useState("idle"); const [ttsError, setTtsError] = useState(null); const [ttsSpeed, setTtsSpeedState] = useState(1); const [speedMenuOpen, setSpeedMenuOpen] = useState(false); const [customSpeedInput, setCustomSpeedInput] = useState(""); const [showCustomInput, setShowCustomInput] = useState(false); const speedBtnRef = useRef(null); const speedMenuRef = useRef(null); const isMobile = useIsMobile(); // Comment compose state const [commentText, setCommentText] = useState(""); const [submittingComment, setSubmittingComment] = useState(false); // Reply state const [replyingToUri, setReplyingToUri] = useState(null); const [replyText, setReplyText] = useState(""); const [submittingReply, setSubmittingReply] = useState(false); // Issue 1 (beads-map-4ek2): Reset reply state when navigating to a different node useEffect(() => { setReplyingToUri(null); setReplyText(""); setSubmittingReply(false); }, [node.id]); const handleSubmitComment = useCallback(async () => { if (!commentText.trim() || submittingComment || !onPostComment) return; setSubmittingComment(true); try { await onPostComment(node.id, commentText.trim()); setCommentText(""); } catch (err) { console.error("Failed to post comment:", err); } finally { setSubmittingComment(false); } }, [commentText, submittingComment, onPostComment, node.id]); const handleStartReply = useCallback((comment: BeadsComment) => { setReplyingToUri(comment.uri); setReplyText(""); }, []); const handleCancelReply = useCallback(() => { setReplyingToUri(null); setReplyText(""); }, []); const handleSubmitReply = useCallback(async () => { if (!replyText.trim() || submittingReply || !onReplyComment || !replyingToUri) return; setSubmittingReply(true); try { // Find the parent comment const findComment = (items: BeadsComment[]): BeadsComment | undefined => { for (const c of items) { if (c.uri === replyingToUri) return c; const found = findComment(c.replies); if (found) return found; } return undefined; }; const parentComment = comments ? findComment(comments) : undefined; if (parentComment) { await onReplyComment(parentComment, replyText.trim()); } setReplyingToUri(null); setReplyText(""); } catch (err) { console.error("Failed to post reply:", err); } finally { setSubmittingReply(false); } }, [replyText, submittingReply, onReplyComment, replyingToUri, comments]); // Selection tooltip state const [selectionTooltip, setSelectionTooltip] = useState<{ text: string; x: number; y: number; } | null>(null); const selectionTooltipRef = useRef(null); // Guard: when true, selectionchange listener won't clear the tooltip // (prevents race where clicking the tooltip clears browser selection before handler runs) const ttsStartingRef = useRef(false); const handleCopy = () => { if (!node.description) return; navigator.clipboard .writeText(buildDescriptionCopyText(node, repoUrl)) .then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }; const handleShareLink = () => { // Issue 3 (beads-map-tn4k): Preserve existing URL params (epic, color, filters, etc.) const params = new URLSearchParams(window.location.search); params.set('node', node.id); params.set('desc', node.id); const url = window.location.origin + window.location.pathname + '?' + params.toString(); navigator.clipboard.writeText(url).then(() => { setSharedLink(true); setTimeout(() => setSharedLink(false), 2000); }); }; // --- TTS handlers ------------------------------------------------------- const ttsStateChange = useCallback((state: TtsState, error?: string) => { setTtsState(state); if (error) setTtsError(error); }, []); const handleTts = useCallback(() => { if (!hasApiKey()) { onOpenSettings?.(); return; } const plainText = stripMarkdown(node.description || ""); if (!plainText) return; setTtsError(null); speakWithElevenLabs(plainText, ttsStateChange); }, [node.description, onOpenSettings, ttsStateChange]); const handleStopTts = useCallback(() => { stopTts(); setTtsState("idle"); setSpeedMenuOpen(false); setShowCustomInput(false); }, []); const handlePauseTts = useCallback(() => { pauseTts(); setTtsState("paused"); }, []); const handleResumeTts = useCallback(() => { resumeTts(); setTtsState("playing"); }, []); const handleSpeedChange = useCallback((speed: number) => { const clamped = Math.max(0.25, Math.min(4, speed)); setTtsSpeedState(clamped); setTtsPlaybackRate(clamped); setSpeedMenuOpen(false); setShowCustomInput(false); }, []); // Apply speed when playback starts (carries over from previous session) useEffect(() => { if (ttsState === "playing") { setTtsPlaybackRate(ttsSpeed); } }, [ttsState, ttsSpeed]); // Click-outside handler for speed menu useEffect(() => { if (!speedMenuOpen) return; const handler = (e: MouseEvent) => { if ( speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node) && speedBtnRef.current && !speedBtnRef.current.contains(e.target as Node) ) { setSpeedMenuOpen(false); setShowCustomInput(false); } }; const timer = setTimeout(() => window.addEventListener("mousedown", handler), 50); return () => { clearTimeout(timer); window.removeEventListener("mousedown", handler); }; }, [speedMenuOpen]); // --- Selection tooltip handlers ----------------------------------------- const handleSelectionMouseUp = useCallback(() => { // Small delay to let the browser finalize the selection setTimeout(() => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) return; const selectedText = sel.toString().trim(); if (selectedText.length < 3) return; try { const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); setSelectionTooltip({ text: selectedText, x: rect.left + rect.width / 2, y: rect.top - 8, }); } catch { // getRangeAt can throw if selection is weird } }, 10); }, []); // Clear tooltip when selection is lost (guarded during TTS initiation) // Disabled on mobile — text selection is unreliable on touch devices useEffect(() => { if (isMobile) return; const handler = () => { if (ttsStartingRef.current) return; const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) { setSelectionTooltip(null); } }; document.addEventListener("selectionchange", handler); return () => document.removeEventListener("selectionchange", handler); }, [isMobile]); const handleSelectionTts = useCallback(() => { if (!selectionTooltip) return; const text = selectionTooltip.text; if (!text.trim()) return; if (!hasApiKey()) { onOpenSettings?.(); setSelectionTooltip(null); return; } ttsStartingRef.current = true; // Guard against selectionchange race setTtsError(null); setSelectionTooltip(null); // speakSelection() checks cache first — zero API call if full text was played speakSelection(text, ttsStateChange); // Release guard after a tick (React state updates are batched) setTimeout(() => { ttsStartingRef.current = false; }, 100); }, [selectionTooltip, onOpenSettings, ttsStateChange]); // Stop TTS on unmount / modal close useEffect(() => { return () => { stopTts(); setSpeedMenuOpen(false); setSelectionTooltip(null); }; }, []); // Arrow key navigation + Escape to close useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't capture if user is in an input/textarea const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return; if (e.key === "Escape") { e.preventDefault(); onClose(); } else if (e.key === "ArrowLeft" && onNavigate) { e.preventDefault(); onNavigate("prev"); } else if (e.key === "ArrowRight" && onNavigate) { e.preventDefault(); onNavigate("next"); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose, onNavigate]); if (!node.description) return null; return createPortal(
e.stopPropagation()} > {/* Modal header */}
{node.id} {node.title}
{onNavigate && (
{navigationLabel && ( {navigationLabel} )}
)}
{/* Copy button */} {/* Share link button */} {/* TTS buttons — 4 states: idle, loading, playing, paused */} {ttsState === "loading" ? ( ) : ttsState === "playing" ? ( <> {/* Pause button */} {/* Stop button */} ) : ttsState === "paused" ? ( <> {/* Resume button */} {/* Stop button */} ) : ( /* Idle — play/speaker button */ )} {/* Speed selector — visible during playback/loading/paused */} {(ttsState === "playing" || ttsState === "loading" || ttsState === "paused") && (
{speedMenuOpen && (
e.stopPropagation()} > {SPEED_PRESETS.map((preset) => ( ))}
{!showCustomInput ? ( ) : (
setCustomSpeedInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { const val = parseFloat(customSpeedInput); if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val); } if (e.key === "Escape") { e.stopPropagation(); // Prevent modal close setShowCustomInput(false); setSpeedMenuOpen(false); } }} onBlur={() => { const val = parseFloat(customSpeedInput); if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val); else setShowCustomInput(false); }} autoFocus className="w-16 rounded border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 px-2 py-1 text-xs text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-emerald-500" /> x
)}
)}
)} {/* Close button */}
{/* TTS error banner */} {ttsError && (
{ttsError}
)} {/* Modal body */}
{node.description} {node.acceptanceCriteria && ( <>

Acceptance Criteria

                
                  {node.acceptanceCriteria}
                
              
)} {/* Comments section */}

Comments {comments && comments.length > 0 && ( {countAllComments(comments)} )}

{/* Comment list */} {comments && comments.length > 0 ? (
{comments.map((comment) => ( ))}
) : (

No comments yet.

)} {/* Compose area or sign-in prompt */} {isAuthenticated ? (
{session?.avatar ? ( ) : (
{session?.handle?.charAt(0).toUpperCase() ?? "?"}
)}
{session?.handle}