/** * Right-hand comment drawer: lists this session's submitted comments (pin * number, text, reviewer, time, status) and scrolls to / pulses the matching * pin on click. Toggled by a small floating button that appears above the * trigger once at least one comment exists. Lives entirely in the shadow root. * * Also owns the on-page thread popover: clicking a pin opens its comment * thread right at the pin (same entries, same Retry/Discard/Follow up * actions), so the drawer is the overview and the pin is the door. */ import type { Drawer, Pin, Runtime } from "../index"; import { buildPayload } from "./panel"; import { send } from "../transport/webhook"; const LIST_ICON = '"; export function createDrawer(rt: Runtime): Drawer { let el: HTMLElement | null = null; let list: HTMLElement | null = null; let count: HTMLElement; let toggle: HTMLButtonElement; let eye: HTMLButtonElement | null = null; /** Thread popover state (open at most one, anchored to its root pin). */ let pop: HTMLElement | null = null; let popList: HTMLElement | null = null; let popPin: Pin | null = null; function submitted(): Pin[] { return rt.pins.filter((p) => p.status !== "pending"); } function isOpen(): boolean { return !!el; } function onKey(e: KeyboardEvent): void { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); } } function fmtTime(iso: string): string { if (!iso) return ""; try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } } /** A reply's page presence is its root pin — resolve to it for scroll/pulse. */ function rootOf(p: Pin): Pin { if (p.replyToId === null) return p; return rt.pins.find((q) => q.replyToId === null && q.id === p.replyToId) || p; } function locate(p: Pin): void { const target = rootOf(p); // If the pin would sit under the open drawer, close the drawer so the // pulse is actually visible (pins themselves stay on the page). if (el) { const dw = el.offsetWidth || 320; if (target.docX - window.scrollX >= window.innerWidth - dw) close(); } window.scrollTo({ top: Math.max(0, target.docY - window.innerHeight / 2), behavior: "smooth", }); if (target.el) { target.el.classList.remove("pulse"); // restart the animation if the same pin is clicked twice void target.el.offsetWidth; target.el.classList.add("pulse"); } } /** Resend a failed comment (drawer path — used after a reload, when the * composer's own Retry is long gone). Original write time is preserved. */ async function retry(p: Pin, btn: HTMLButtonElement): Promise { if (btn.disabled) return; btn.disabled = true; btn.textContent = "Sending…"; let ok = false; let sentId: string | null = null; try { const payload = buildPayload(rt, p, p.body, p.reviewer, p.at || undefined); sentId = payload.id; ok = (await send(rt.cfg.webhook, rt.cfg.transport, payload)).ok; } catch { ok = false; } if (p.status !== "failed") return; // discarded or re-sent elsewhere mid-flight if (ok) { p.status = "sent"; p.deliveredId = sentId; if (p.el) { p.el.classList.remove("failed"); p.el.classList.add("sent"); } rt.savePins(); rt.saveReceipts(); refresh(); } else { btn.disabled = false; btn.textContent = "Retry"; } } /** Drop an unsent comment for good (it never reached the destination). */ function discard(p: Pin): void { rt.overlay.removePin(p); // also renumbers and refreshes this drawer rt.savePins(); } /** "Got it": the reviewer acknowledges a closed comment — its mark is done. * Dismissing a root takes its replies with it (the thread closed together). */ function acknowledge(p: Pin): void { for (const c of rt.pins.filter((q) => q.replyToId === p.id)) rt.overlay.removePin(c); rt.overlay.removePin(p); rt.saveReceipts(); } /** * Follow-up: bring the root pin on screen and open the composer on a * marker-less reply that carries the root's anchor. Following up on a reply * joins the same thread. No prefill: the composer shows a "Replying to #N" * chip and submit adds the "Re #N:" prefix at send time. */ function followUp(p: Pin): void { closeThread(); close(); const root = rootOf(p); locate(root); const np = rt.overlay.addPin(root.docX, root.docY, root.anchor, root.id); rt.panel.open(np); } function threadEntries(root: Pin): Pin[] { const pins = submitted(); return [root, ...pins.filter((p) => p.replyToId === root.id)]; } function renderList(): void { if (!list) return; list.textContent = ""; const pins = submitted(); if (!pins.length) { const empty = document.createElement("div"); empty.className = "drawer-empty"; empty.textContent = "No comments yet."; list.appendChild(empty); return; } const kids = new Map(); const roots: Pin[] = []; for (const p of pins) { if ( p.replyToId !== null && pins.some((q) => q.replyToId === null && q.id === p.replyToId) ) { const arr = kids.get(p.replyToId) || []; arr.push(p); kids.set(p.replyToId, arr); } else { roots.push(p); // top-level, or a reply whose root is gone } } for (const p of roots) { list.appendChild(makeEntry(p, false, true)); for (const c of kids.get(p.id) || []) list.appendChild(makeEntry(c, true, true)); } } function makeEntry(p: Pin, reply: boolean, locatable: boolean): HTMLElement { const thread = rootOf(p); const threadNumber = thread.n || p.n; const entry = document.createElement("div"); entry.className = "entry" + (p.status === "failed" ? " failed" : "") + (reply ? " reply" : "") + (p.foreign ? " foreign" : "") + (p.receipt ? (p.receipt.status === "resolved" ? " resolved" : " declined") : ""); const go = document.createElement("button"); go.type = "button"; go.className = "entry-go"; go.setAttribute( "aria-label", reply ? "Go to reply on comment " + threadNumber : "Go to comment " + threadNumber, ); const n = document.createElement("span"); n.className = "n"; n.textContent = reply ? "↳" : String(p.n); go.appendChild(n); const main = document.createElement("span"); main.className = "entry-main"; const body = document.createElement("span"); body.className = "body"; // Nesting already shows the linkage — hide the "Re #N:" prefix (the sent // payload keeps it). const text = reply ? (p.body || "").replace(/^Re #\d+:\s*/, "") : p.body; body.textContent = text || "(no text)"; main.appendChild(body); const meta = document.createElement("span"); meta.className = "meta"; const bits: string[] = []; // Someone else's comment is always attributed, even when they left the // name field blank — "who said this" is the whole point of a shared review, // and an unattributed entry would read as the reviewer's own. if (p.reviewer) bits.push(p.reviewer); else if (p.foreign) bits.push("Another reviewer"); const t = fmtTime(p.at); if (t) bits.push(t); if (p.status === "failed") bits.push("not sent"); if (p.receipt) bits.push(p.receipt.status === "resolved" ? "✓ resolved" : "✕ declined"); meta.textContent = bits.join(" · "); main.appendChild(meta); if (p.receipt && p.receipt.note) { const note = document.createElement("span"); note.className = "note"; note.textContent = p.receipt.note; main.appendChild(note); } // AI acknowledgement: a thread message under the comment, never a status // change (the pin stays open — this does not touch p.receipt). if (p.aiReply) { const ai = document.createElement("span"); ai.className = "ai-reply"; ai.textContent = "🤖 " + p.aiReply; main.appendChild(ai); } go.appendChild(main); if (locatable) { go.addEventListener("click", () => locate(p)); } else { go.disabled = true; // popover entries are already AT the pin } entry.appendChild(go); const actions = document.createElement("div"); actions.className = "entry-actions"; if (p.status === "failed") { const rb = document.createElement("button"); rb.type = "button"; rb.className = "retry"; rb.textContent = "Retry"; rb.setAttribute( "aria-label", reply ? "Retry sending reply on comment " + threadNumber : "Retry sending comment " + threadNumber, ); rb.addEventListener("click", () => void retry(p, rb)); actions.appendChild(rb); const db = document.createElement("button"); db.type = "button"; db.className = "discard"; db.textContent = "Discard"; db.setAttribute( "aria-label", reply ? "Discard reply on comment " + threadNumber : "Discard comment " + threadNumber, ); db.addEventListener("click", () => discard(p)); actions.appendChild(db); } // "Got it" retires a closed comment from this reviewer's page. It is only // offered on your OWN comments: dismissing someone else's would be undone // by the next shared poll, which re-adds anything still in the view. if (p.receipt && p.replyToId === null && !p.foreign) { const ok = document.createElement("button"); ok.type = "button"; ok.className = "gotit"; ok.textContent = "Got it"; ok.setAttribute("aria-label", "Dismiss closed comment " + threadNumber); ok.addEventListener("click", () => acknowledge(p)); actions.appendChild(ok); } const fu = document.createElement("button"); fu.type = "button"; fu.className = "followup"; fu.textContent = "Follow up"; fu.setAttribute("aria-label", "Add follow-up to comment " + threadNumber); fu.addEventListener("click", () => followUp(p)); actions.appendChild(fu); entry.appendChild(actions); return entry; } /* ---- thread popover (opened by clicking a pin) ---- */ function onDocClick(e: MouseEvent): void { if (!pop) return; const path = e.composedPath(); for (const t of path) { if (t === pop) return; // clicks inside the popover keep it open if (t instanceof HTMLElement && t.classList && t.classList.contains("pin")) return; } closeThread(); } function onPopKey(e: KeyboardEvent): void { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeThread(true); } } function positionThread(root: Pin): void { if (!pop) return; const vw = window.innerWidth; const vh = window.innerHeight; const pw = pop.offsetWidth || 280; const ph = pop.offsetHeight || 160; const gap = 16; const cx = root.docX - window.scrollX; const cy = root.docY - window.scrollY; let left = cx + gap; if (left + pw > vw - 8) left = cx - gap - pw; if (left < 8) left = 8; let top = cy + gap; if (top + ph > vh - 8) top = cy - gap - ph; if (top < 8) top = 8; pop.style.left = left + "px"; pop.style.top = top + "px"; } function renderThread(): void { if (!popList || !popPin) return; popList.textContent = ""; for (const p of threadEntries(popPin)) { popList.appendChild(makeEntry(p, p !== popPin, false)); } } function openThread(p: Pin): void { const root = rootOf(p); if (root.status === "pending") return; if (pop && popPin === root) { closeThread(true); // clicking the pin again toggles its thread away return; } closeThread(); if (el) close(); // the pin is the door now; the drawer would cover it popPin = root; pop = document.createElement("div"); pop.className = "thread"; pop.setAttribute("role", "dialog"); pop.setAttribute("aria-label", "Comment " + root.n + " thread"); pop.addEventListener("keydown", onPopKey); const head = document.createElement("div"); head.className = "thread-head"; const title = document.createElement("span"); title.textContent = "Comment #" + root.n; const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.textContent = "Close"; closeBtn.setAttribute("aria-label", "Close thread"); closeBtn.addEventListener("click", () => closeThread(true)); head.appendChild(title); head.appendChild(closeBtn); pop.appendChild(head); popList = document.createElement("div"); popList.className = "thread-list"; pop.appendChild(popList); renderThread(); rt.root.appendChild(pop); positionThread(root); document.addEventListener("click", onDocClick, true); closeBtn.focus(); } function closeThread(refocusPin?: boolean): void { if (!pop) return; const pin = popPin; pop.remove(); pop = null; popList = null; popPin = null; document.removeEventListener("click", onDocClick, true); if (refocusPin && pin && pin.el && pin.el.isConnected) pin.el.focus(); } /* ---- drawer panel ---- */ function open(): void { if (el) return; closeThread(); el = document.createElement("div"); el.className = "drawer"; el.setAttribute("role", "complementary"); el.setAttribute("aria-label", "Feedback comments"); el.addEventListener("keydown", onKey); const head = document.createElement("div"); head.className = "drawer-head"; const title = document.createElement("span"); title.textContent = "Comments (" + submitted().length + ")"; const controls = document.createElement("div"); controls.className = "drawer-controls"; eye = document.createElement("button"); eye.type = "button"; eye.textContent = rt.overlay.pinsHidden() ? "Show pins" : "Hide pins"; eye.addEventListener("click", () => { rt.overlay.setPinsHidden(!rt.overlay.pinsHidden()); if (eye) eye.textContent = rt.overlay.pinsHidden() ? "Show pins" : "Hide pins"; }); const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.textContent = "Close"; closeBtn.setAttribute("aria-label", "Close comments"); closeBtn.addEventListener("click", close); controls.appendChild(eye); controls.appendChild(closeBtn); head.appendChild(title); head.appendChild(controls); el.appendChild(head); list = document.createElement("div"); list.className = "drawer-list"; el.appendChild(list); renderList(); rt.root.appendChild(el); rt.overlay.syncPins(); // Opening the overview is the natural moment to refresh both halves of the // picture: what happened to your comments, and what others have added. rt.checkReceipts(); rt.fetchShared(true); // explicit "what's there now?" — not subject to the poll floor toggle.setAttribute("aria-expanded", "true"); closeBtn.focus(); } function close(): void { if (!el) return; el.remove(); el = null; list = null; eye = null; // Pins stay on the page (overlay policy); this just updates emphasis. rt.overlay.syncPins(); toggle.setAttribute("aria-expanded", "false"); toggle.focus(); } function refresh(): void { const pins = submitted(); const n = pins.length; count.textContent = String(n); toggle.classList.toggle("hidden", n === 0 && !el); // Unsent comments need the reviewer's attention — flag the badge red. toggle.classList.toggle("failed", pins.some((p) => p.status === "failed")); if (el) { const title = el.querySelector(".drawer-head span"); if (title) title.textContent = "Comments (" + n + ")"; renderList(); } if (pop) { // Re-render the open thread; close it if its root pin is gone. if (popPin && rt.pins.indexOf(popPin) >= 0) renderThread(); else closeThread(); } rt.overlay.syncPins(); } function destroy(): void { closeThread(); if (el) { el.remove(); el = null; list = null; eye = null; } toggle.remove(); } toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "list-toggle pos-" + rt.cfg.position + " hidden"; toggle.setAttribute("aria-label", "View comments"); toggle.setAttribute("aria-expanded", "false"); toggle.innerHTML = LIST_ICON; count = document.createElement("span"); count.className = "count"; count.setAttribute("aria-hidden", "true"); count.textContent = "0"; toggle.appendChild(count); toggle.addEventListener("click", () => (el ? close() : open())); rt.root.appendChild(toggle); return { open, close, isOpen, openThread, refresh, destroy }; }