/* global React, PR */ const { useState, useRef, useEffect, useCallback } = React; const { Avatar } = PR; const fmtTime = (iso) => { if (!iso) return ""; const d = new Date(iso); const diff = Math.floor((Date.now() - d.getTime()) / 60000); if (diff < 1) return "just now"; if (diff < 60) return diff + "m ago"; if (diff < 1440) return Math.floor(diff / 60) + "h ago"; return Math.floor(diff / 1440) + "d ago"; }; const EMOJI_PICKER = ["πŸ‘","πŸ‘Ž","🎯","πŸ”₯","πŸ’‘","βœ…","πŸ€”","πŸ‘€","πŸš€","❀️"]; // ── Lightbox ────────────────────────────────────────────────────────────────── function Lightbox({ src, onClose }) { useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", h); return () => document.removeEventListener("keydown", h); }, [onClose]); return (
e.stopPropagation()} />
); } // ── Attachment display ──────────────────────────────────────────────────────── function AttachmentDisplay({ attachments }) { const [lightbox, setLightbox] = useState(null); const images = attachments.filter(a => a.kind === "image" && a.url); const files = attachments.filter(a => a.kind !== "image" || !a.url); return ( <> {images.length > 0 && (
{images.map((a, i) => ( {a.name} setLightbox(a.url)} /> ))}
)} {files.length > 0 && (
{files.map((a, i) => (
{a.name} {a.size} {a.url && ( e.stopPropagation()}> Download )}
))}
)} {lightbox && setLightbox(null)} />} ); } function renderBody(text, authorMap) { const parts = text.split(/(@\w+(?:\s+\w+)?)/g); return parts.map((p, i) => { if (p.startsWith("@")) { const id = p.slice(1).trim(); const author = authorMap[id]; const display = author ? author.name : id; return @{display}; } return {p}; }); } function EmojiMenu({ onPick, onClose }) { const ref = useRef(null); useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; document.addEventListener("mousedown", h); return () => document.removeEventListener("mousedown", h); }, [onClose]); return (
{EMOJI_PICKER.map(e => ( ))}
); } function Comment({ c, authorMap, hypothesisId, onReload, depth = 0, currentUserId }) { const [showReply, setShowReply] = useState(false); const [showEmoji, setShowEmoji] = useState(false); const author = authorMap[c.author]; const handleReact = async (emoji) => { await fetch(`/api/hypotheses/${hypothesisId}/comments/${c.id}/react`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ emoji }), }); onReload(); }; return (
{author?.name || c.author} {fmtTime(c.time)}
{renderBody(c.body, authorMap)}
{c.attachments && c.attachments.length > 0 && ( )}
{c.reactions.map((r, i) => ( authorMap[u]?.name || u).join(", ")} onClick={() => handleReact(r.emoji)} > {r.emoji} {r.users.length} ))}
{showEmoji && setShowEmoji(false)} />}
{depth === 0 && ( )}
{c.replies && c.replies.length > 0 && (
{c.replies.map(r => ( ))}
)} {showReply && depth === 0 && (
{ setShowReply(false); onReload(); }} currentUserId={currentUserId} />
)}
); } function Composer({ authorMap, placeholder = "Write a comment… use @ to mention", compact, hypothesisId, parentId, onPosted, currentUserId }) { const [text, setText] = useState(""); const [focused, setFocused] = useState(false); const [attached, setAttached] = useState([]); const [mentionState, setMentionState] = useState(null); const [activeIdx, setActiveIdx] = useState(0); const [sending, setSending] = useState(false); const taRef = useRef(null); const fileRef = useRef(null); const authors = Object.values(authorMap).filter(a => a.status !== "deactivated"); const matches = mentionState ? authors.filter(a => a.name.toLowerCase().includes(mentionState.query.toLowerCase())).slice(0, 6) : []; const onChange = (e) => { const v = e.target.value; setText(v); const cur = e.target.selectionStart; const before = v.slice(0, cur); const m = before.match(/@(\w*)$/); if (m) { setMentionState({ query: m[1], pos: cur }); setActiveIdx(0); } else setMentionState(null); }; const insertMention = (a) => { if (!taRef.current) return; const cur = taRef.current.selectionStart; const before = text.slice(0, cur); const after = text.slice(cur); const replaced = before.replace(/@\w*$/, `@${a.id} `); setText(replaced + after); setMentionState(null); setTimeout(() => taRef.current?.focus(), 0); }; const onKeyDown = (e) => { if (mentionState && matches.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((activeIdx + 1) % matches.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((activeIdx - 1 + matches.length) % matches.length); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(matches[activeIdx]); return; } if (e.key === "Escape") { setMentionState(null); return; } } if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleSend(); } }; const onPickFile = async (e) => { const files = Array.from(e.target.files || []); e.target.value = ""; for (const f of files) { const isImage = f.type.startsWith("image/"); const sizeFmt = f.size < 1048576 ? `${Math.round(f.size / 1024)} KB` : `${(f.size / 1048576).toFixed(1)} MB`; const kind = isImage ? "image" : f.name.endsWith(".pdf") ? "pdf" : "file"; // Upload to server so we have a permanent URL try { const form = new FormData(); form.append("file", f); const resp = await fetch("/api/upload/attachment", { method: "POST", body: form }); if (resp.ok) { const { url } = await resp.json(); setAttached(a => [...a, { name: f.name, size: sizeFmt, kind, url }]); } else { // Fallback: store metadata only (no download/preview) setAttached(a => [...a, { name: f.name, size: sizeFmt, kind }]); } } catch { setAttached(a => [...a, { name: f.name, size: sizeFmt, kind }]); } } }; const handleSend = async () => { if (!text.trim() && attached.length === 0) return; setSending(true); try { await fetch(`/api/hypotheses/${hypothesisId}/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: text.trim(), parent_id: parentId || null, attachments: attached }), }); setText(""); setAttached([]); onPosted && onPosted(); } finally { setSending(false); } }; const currentAuthor = authorMap[String(currentUserId)]; return (
{attached.length > 0 && (
{attached.map((a, i) => ( {a.kind === "image" && a.url ? {a.name} : } {a.name} {a.size} ))}
)}