/* ================================================================== Doubts — Student↔Teacher per-question threads ------------------------------------------------------------------ Left column: list of doubts the calling user can see (test author + HoD/admin for the owning school). Tabs filter by status. Right column: selected thread — original question, what the student picked, the message timeline, and a reply composer (text + image). The bell in shell.jsx surfaces 'doubt_raised' / 'doubt_message' notifications; clicking one navigates here with the doubt preselected (via window.APP.DOUBTS_CONTEXT seeded by app.jsx). ================================================================== */ const { Icon: DbIcon } = window.AppUI; const STATUS_LABEL = { open: "Open", answered: "Replied", resolved: "Resolved" }; const STATUS_CLS = { open: "amber", answered: "blue", resolved: "green" }; const relTimeD = (iso) => { if (!iso) return ""; const ms = Date.now() - new Date(iso).getTime(); const s = Math.floor(ms / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s/60)}m ago`; if (s < 86400) return `${Math.floor(s/3600)}h ago`; return `${Math.floor(s/86400)}d ago`; }; const DoubtsScreen = () => { const [items, setItems] = React.useState([]); const [counts, setCounts] = React.useState({ open: 0, answered: 0, resolved: 0 }); const [tab, setTab] = React.useState("open"); const [selectedId, setSelectedId] = React.useState(() => window.APP?.DOUBTS_CONTEXT?.doubtId || null); const [search, setSearch] = React.useState(""); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const refresh = React.useCallback(async () => { setLoading(true); setErr(null); try { const [list, c] = await Promise.all([ window.AppApi.get(`/teacher/doubts${tab === "all" ? "" : `?status=${tab}`}`), window.AppApi.get("/teacher/doubts/counts"), ]); setItems(list || []); setCounts(c || { open: 0, answered: 0, resolved: 0 }); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [tab]); React.useEffect(() => { refresh(); }, [refresh]); // Clear the cross-screen context once we've consumed it. The selectedId // is now state-owned so the user can navigate freely from here. React.useEffect(() => { if (window.APP?.DOUBTS_CONTEXT) { // If the context's doubt belongs to a different status tab, switch. const ctx = window.APP.DOUBTS_CONTEXT; if (ctx.tab && ctx.tab !== tab) setTab(ctx.tab); delete window.APP.DOUBTS_CONTEXT; } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const filtered = items.filter((d) => { if (!search) return true; const q = search.toLowerCase(); return ( (d.studentName || "").toLowerCase().includes(q) || (d.studentRoll || "").toLowerCase().includes(q) || (d.testTitle || "").toLowerCase().includes(q) || (d.testDisplayId || "").toLowerCase().includes(q) || (d.qTextPreview || "").toLowerCase().includes(q) ); }); const selected = filtered.find((d) => d.id === selectedId) || items.find((d) => d.id === selectedId) || null; return (
{/* ─── Left: list ─── */}
Student doubts

Conversations

Threads opened by students from the report screen. You see threads on tests you authored; HoDs and admins see the whole school.

{/* Tabs */}
{["open", "answered", "resolved"].map((t) => { const active = tab === t; return ( ); })}
{/* Search */}
setSearch(e.target.value)} placeholder="Search student, test, question…" style={{ width: "100%", padding: "8px 10px 8px 30px", fontSize: 12.5, background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink-0)", }}/>
{err &&
{err}
} {loading &&
Loading…
}
{!loading && filtered.length === 0 && (
No {STATUS_LABEL[tab].toLowerCase()} doubts.
)} {filtered.map((d) => { const active = d.id === selectedId; return ( ); })}
{/* ─── Right: detail ─── */} {selected ? ( ) : (
Select a doubt to read and reply.
)}
); }; // Render any $…$ / \(…\) / \[…\] math inside a container after React has // flushed. KaTeX's auto-render lib is loaded via index.html. We swallow // failures so an unsupported delimiter doesn't tear down the screen. const KATEX_DELIMS = [ { left: "$$", right: "$$", display: true }, { left: "\\[", right: "\\]", display: true }, { left: "$", right: "$", display: false }, { left: "\\(", right: "\\)", display: false }, ]; const renderMathIn = (el) => { if (!el || typeof window.renderMathInElement !== "function") return; try { window.renderMathInElement(el, { delimiters: KATEX_DELIMS, throwOnError: false, ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code", "input"], }); } catch {} }; const DoubtDetail = ({ doubtId, onRefresh }) => { const [thread, setThread] = React.useState(null); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const [draft, setDraft] = React.useState(""); const [pending, setPending] = React.useState([]); // [{ kind, url, filename, mime, size }] const [uploading, setUploading] = React.useState(false); const [sending, setSending] = React.useState(false); const fileRef = React.useRef(null); const scrollRef = React.useRef(null); const mathRootRef = React.useRef(null); const load = React.useCallback(async () => { setLoading(true); setErr(null); try { const data = await window.AppApi.get(`/teacher/doubts/${doubtId}`); setThread(data); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }, [doubtId]); React.useEffect(() => { load(); }, [load]); // Scroll to the latest message whenever the thread updates. React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [thread?.messages?.length]); // After the thread renders (or its message count grows), pass over the // question card + message bodies so KaTeX replaces inline TeX. React.useEffect(() => { if (thread) renderMathIn(mathRootRef.current); }, [thread?.doubt?.id, thread?.messages?.length]); const upload = async (file) => { if (!file) return; setUploading(true); setErr(null); try { const form = new FormData(); form.append("file", file); const token = window.AppApi.getToken(); const r = await fetch(window.AppApi.base + "/teacher/doubts/upload-attachment", { method: "POST", headers: token ? { authorization: `Bearer ${token}` } : {}, body: form, }); if (!r.ok) throw new Error(`upload ${r.status}`); const att = await r.json(); setPending((xs) => [...xs, att]); } catch (e) { setErr(String(e.message || e)); } finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; } }; const send = async () => { const body = draft.trim(); if (!body && pending.length === 0) return; setSending(true); setErr(null); try { await window.AppApi.post(`/teacher/doubts/${doubtId}/messages`, { body, attachments: pending }); setDraft(""); setPending([]); await load(); onRefresh?.(); } catch (e) { setErr(String(e.message || e)); } finally { setSending(false); } }; const resolve = async () => { if (!window.confirm("Mark this doubt as resolved? The student is notified.")) return; try { await window.AppApi.post(`/teacher/doubts/${doubtId}/resolve`, {}); await load(); onRefresh?.(); } catch (e) { setErr(String(e.message || e)); } }; if (loading) return
Loading…
; if (err) return
{err}
; if (!thread) return null; const d = thread.doubt; const q = thread.question; const sr = thread.studentResponse; const picked = q && sr?.selectedOptionId ? q.options.find((o) => o.id === sr.selectedOptionId) : null; return (
{/* Header */}
{STATUS_LABEL[d.status]} {d.testDisplayId} · Q{d.qNo} {d.testTitle}
{d.status !== "resolved" && ( )}
From {d.studentName} ({d.studentRoll})  ·  opened {relTimeD(d.createdAt)}
{/* Math-renderable region: question card + message bodies. */}
{/* Question card */} {q && (
Question · {q.marks} mark{q.marks === 1 ? "" : "s"}
{q.text}
{q.options.length > 0 && (
{q.options.map((o) => { const isCorrect = o.is_correct; const isPicked = picked?.id === o.id; return (
{o.letter}. {o.text} {isCorrect && correct} {isPicked && !isCorrect && student picked} {isPicked && isCorrect && (student picked)}
); })}
)} {q.expected_answer && (
Show stored solution
{q.expected_answer}
)}
)} {/* Messages */}
{thread.messages.length === 0 &&
No messages yet.
} {thread.messages.map((m) => { const isTeacher = m.senderType === "teacher"; return (
{isTeacher ? (m.senderName || "Teacher") : (m.senderName || d.studentName)} {relTimeD(m.createdAt)}
{m.body &&
{m.body}
} {m.attachments?.length > 0 && (
{m.attachments.map((a, i) => a.kind === "image" ? ( {a.filename ) : null )}
)}
); })}
{/* End math-renderable region */} {/* Composer */} {d.status !== "resolved" ? (
{pending.length > 0 && (
{pending.map((a, i) => (
))}
)}