/* 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) => (

setLightbox(a.url)}
/>
))}
)}
{files.length > 0 && (
{files.map((a, i) => (
))}
)}
{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.size}
))}
)}
{mentionState && matches.length > 0 && (
{matches.map((a, i) => (
{ e.preventDefault(); insertMention(a); }}
>
{a.name}
{a.email || a.role}
))}
)}
);
}
function CommentsPanel({ data, hypothesisId }) {
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
const currentUserId = data._meta?.currentUserId;
const authorMap = Object.fromEntries(data.authors.map(a => [a.id, a]));
const loadComments = useCallback(async () => {
const resp = await fetch(`/api/hypotheses/${hypothesisId}/comments`);
if (resp.ok) setComments(await resp.json());
setLoading(false);
}, [hypothesisId]);
useEffect(() => { loadComments(); }, [loadComments]);
const total = comments.reduce((s, c) => s + 1 + (c.replies?.length || 0), 0);
if (loading) return Loading commentsβ¦
;
return (
Comments
{total > 0 && {total}}
{comments.length === 0 ? (
No comments yet. Start the discussion.
) : (
{comments.map(c => (
))}
)}
);
}
window.PRComments = { CommentsPanel };