/** * Public API + shared runtime. ESM entry (NO auto-init). * * import { init, destroy } from "tyrekick"; * * Everything the widget renders lives inside ONE shadow root on a single host * element appended to . We never mutate host-page DOM or styles. */ import type { TyrekickConfig, FeedbackPayload, Position, Transport } from "./types"; import { styles } from "./ui/styles"; import { createTrigger } from "./ui/trigger"; import { createOverlay } from "./ui/overlay"; import { createPanel } from "./ui/panel"; import { createDrawer } from "./ui/drawer"; import { uuid } from "./capture/context"; import { startErrors, stopErrors } from "./capture/errors"; export interface Resolved { webhook: string; appVersion: string; projectName: string; position: Position; accent: string; theme: "auto" | "light" | "dark"; branding: boolean; fieldName: boolean; transport: Transport; persist: boolean; captureErrors: boolean; /** Shared-review key, or null when each reviewer sees only their own pins. */ reviewKey: string | null; } export interface Pin { /** Stable identity for this comment/pin, regardless of UI reordering. */ id: string; /** User-facing display number for top-level pins; replies derive from roots. */ n: number; /** * The number the destination assigned this comment, once it has one. It is a * position in the page's whole history — gaps and all — so it never changes * when another comment is declined, unlike a count taken over what happens to * be on screen. Null while a comment is local-only or the destination does * not number (Discord, an older worker); those fall back to counting. */ serverN: number | null; /** document-space coordinates (survive scroll) */ docX: number; docY: number; /** viewport coordinates at creation (used to place the composer) */ clientX: number; clientY: number; status: "pending" | "sent" | "failed"; /** Root pin id this comment replies to, or null for a top-level comment. */ replyToId: string | null; /** * Click position as a fraction of the anchored element's box (0..1), or null * when there is no element anchor. Lets pins re-attach to their element after * reload/resize instead of trusting stale document coordinates. */ fx: number | null; fy: number | null; /** * The payload id of the DELIVERED comment (uuid; differs from Pin.id — a new * payload id is minted per send attempt). It is the capability used to look * up fix-status on the worker's /receipts route. Null until a send succeeds; * meaningless on the Discord transport (write-only, no read-back). */ deliveredId: string | null; /** Closure state pulled back from the worker, or null while open/unknown. */ receipt: { status: "resolved" | "declined"; note: string | null; at: string | null } | null; /** * A one-sentence AI acknowledgement of this comment, pulled back from the * worker's /receipts route, or null while none. Runtime-only exactly like * `receipt`: fetched every load, NEVER persisted to localStorage. It is a * thread message, not a status change — it never touches `receipt` or the * pin's open/closed state. */ aiReply: string | null; /** * True when this pin is SOMEONE ELSE's comment, pulled from the worker's * /shared view (reviewKey set). Foreign pins are strictly read-only: they * are never persisted to localStorage, never retried or discarded, and never * counted as this reviewer's unsent work. They exist so a reviewer can see * what has already been said before saying it again. */ foreign: boolean; anchor: FeedbackPayload["anchor"]; /** comment text as submitted (empty while pending) */ body: string; reviewer: string | null; /** ISO timestamp of submission ("" while pending) */ at: string; el: HTMLElement | null; } export interface Overlay { enter(): void; exit(): void; layout(): void; captureOn(): void; captureOff(): void; /** Create a pending pin; pass `replyToId` (a root pin's stable id) for a * marker-less reply (used by drawer follow-ups). */ addPin(docX: number, docY: number, anchor: Pin["anchor"], replyToId?: string | null): Pin; removePin(p: Pin): void; /** * Reconcile pin presentation with runtime state: pins render whenever any * exist (unless the reviewer hid them), dimmed when the widget is idle, with * scroll/resize tracking owned here so visible pins can never drift. */ syncPins(): void; /** Reviewer's "hide pins" preference (drawer eye toggle); comment mode overrides it. */ setPinsHidden(hidden: boolean): void; pinsHidden(): boolean; isActive(): boolean; focus(): void; destroy(): void; } export interface Drawer { open(): void; close(): void; isOpen(): boolean; /** Open the thread popover for a pin's comment, anchored at the pin. */ openThread(p: Pin): void; refresh(): void; destroy(): void; } export interface Panel { open(p: Pin, prefill?: string): void; close(restoreFocus: boolean): void; isOpen(): boolean; /** If the composer is open but untouched, discard its pending pin and close; returns whether it did. */ abandonClean(): boolean; /** Close like Esc: abandon a pending pin but KEEP the saved draft text. */ dismiss(restoreFocus: boolean): void; destroy(): void; } export interface Draft { body: string; name: string; } export interface Runtime { cfg: Resolved; root: ShadowRoot; host: HTMLElement; sessionId: string; pins: Pin[]; overlay: Overlay; panel: Panel; drawer: Drawer; trigger: HTMLButtonElement; pendingDraft: Draft | null; savePins(): void; saveDraft(d: Draft): void; clearDraft(): void; /** Persist delivered-comment receipts (worker transport only, persist-gated). */ saveReceipts(): void; /** Ask the worker for fix-status on restored delivered comments (throttled, silent). */ checkReceipts(): void; /** * Pull every reviewer's pins from the worker's /shared view (silent, and * throttled unless `force` — see SHARED_POLL_MIN_MS). */ fetchShared(force?: boolean): void; } let rt: Runtime | null = null; let resizeCb: (() => void) | null = null; let resizeTimer: ReturnType | null = null; /** Delivered ids restored from the receipts store — the set worth polling. */ const receiptWatch = new Set(); let lastReceiptCheck = 0; /* ---- SPA navigation: state is keyed by pathname, so it must follow the route ---- */ type HistoryFn = (data: unknown, unused: string, url?: string | URL | null) => void; /** The pathname whose pins are currently loaded; state resets when it changes. */ let lastPath = ""; let popCb: (() => void) | null = null; let origPushState: HistoryFn | null = null; let origReplaceState: HistoryFn | null = null; /** How long a delivered-comment receipt stays worth restoring/polling. */ const RECEIPT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; const RECEIPT_MAX_COUNT = 50; const RECEIPT_POLL_MIN_MS = 60_000; /** * Shared-view poll throttle for BACKGROUND refreshes (init, route change). * A user-initiated refresh (opening the drawer) ignores this and is instead * guarded by `sharedInFlight` — asking to see the overview is an explicit * request for what is there now, and a 60s floor would make the first open * after page load, the very moment the question is asked, always stale. */ const SHARED_POLL_MIN_MS = 60_000; let lastSharedFetch = 0; /** One shared request at a time: makes click-spamming the drawer harmless. */ let sharedInFlight = false; /** * Re-derive each root pin's document position from its anchored element's * CURRENT box and the stored click fraction. Pins whose selector no longer * resolves (or that predate fx/fy) keep their raw coordinates. */ function reprojectPins(): void { if (!rt) return; for (const p of rt.pins) { if (p.replyToId !== null) continue; // replies have no page position of their own if (p.fx === null || p.fy === null || !p.anchor || !p.anchor.selector) continue; let el: Element | null = null; try { el = document.querySelector(p.anchor.selector); } catch { continue; // selector invalid in this DOM — keep stored coords } if (!el) continue; const r = el.getBoundingClientRect(); if (!r.width && !r.height) continue; // display:none or detached layout p.docX = window.scrollX + r.left + p.fx * r.width; p.docY = window.scrollY + r.top + p.fy * r.height; } rt.overlay.layout(); } /** * Client-side navigation (SPA) changes location.pathname without a reload, so * the once-at-init restore() never re-runs and a route's pins would otherwise * linger over the next view. On a real pathname change we reset per-route state * — tear down the current pins, reload storage under the new key, and reproject * once the new route has painted. Query/hash-only changes are ignored because * storage is keyed by pathname alone. */ function resetRoute(): void { if (!rt) return; // Drop any in-flight composing/reading surfaces from the previous route. if (rt.panel.isOpen()) rt.panel.dismiss(false); if (rt.drawer.isOpen()) rt.drawer.close(); // Tear down the previous route's pins (elements + in-memory list). for (const p of rt.pins) { if (p.el) p.el.remove(); p.el = null; } rt.pins.length = 0; rt.pendingDraft = null; receiptWatch.clear(); lastReceiptCheck = 0; // The shared view is per-route, so the new route must be free to fetch // immediately rather than waiting out the previous route's throttle. lastSharedFetch = 0; // Reload state for the route we've arrived at. if (rt.cfg.persist) restore(); rt.overlay.syncPins(); rt.drawer.refresh(); checkReceipts(); fetchShared(); // The framework may not have painted the new DOM yet; reproject now (raw // coords are the fallback) and again next frame once selectors can resolve. reprojectPins(); if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => reprojectPins()); } } /** Fire on any navigation; only a genuine pathname change triggers a reset. */ function onNavigate(): void { if (!rt) return; if (location.pathname === lastPath) return; lastPath = location.pathname; resetRoute(); } function startNav(): void { popCb = onNavigate; window.addEventListener("popstate", popCb); // browser back/forward // pushState/replaceState don't emit an event — wrap them (reverted in destroy). try { const wrap = (orig: HistoryFn): HistoryFn => function (this: History, data: unknown, unused: string, url?: string | URL | null) { const r = orig.call(this, data, unused, url); // Defer so the router can update the DOM before we reset/reproject. queueMicrotask(onNavigate); return r; }; origPushState = history.pushState.bind(history) as HistoryFn; origReplaceState = history.replaceState.bind(history) as HistoryFn; history.pushState = wrap(origPushState) as History["pushState"]; history.replaceState = wrap(origReplaceState) as History["replaceState"]; } catch { /* history unavailable — popstate still covers back/forward */ } } function stopNav(): void { if (popCb) { window.removeEventListener("popstate", popCb); popCb = null; } try { if (origPushState) history.pushState = origPushState as History["pushState"]; if (origReplaceState) history.replaceState = origReplaceState as History["replaceState"]; } catch { /* ignore */ } origPushState = null; origReplaceState = null; } /* ---- theme (tk-light / tk-dark host class; "auto" tracks the OS live) ---- */ let themeMq: MediaQueryList | null = null; let themeCb: ((e: MediaQueryListEvent) => void) | null = null; function setThemeClass(host: HTMLElement, dark: boolean): void { host.classList.toggle("tk-dark", dark); host.classList.toggle("tk-light", !dark); } function startTheme(host: HTMLElement, theme: Resolved["theme"]): void { if (theme === "auto" && typeof window.matchMedia === "function") { try { themeMq = window.matchMedia("(prefers-color-scheme: dark)"); themeCb = (e: MediaQueryListEvent) => setThemeClass(host, e.matches); themeMq.addEventListener("change", themeCb); setThemeClass(host, themeMq.matches); return; } catch { /* fall through to light */ } } setThemeClass(host, theme === "dark"); } function stopTheme(): void { if (themeMq && themeCb) { try { themeMq.removeEventListener("change", themeCb); } catch { /* ignore */ } } themeMq = null; themeCb = null; } export function init(config: TyrekickConfig): void { if (rt) { console.warn("[Tyrekick] already initialised — call destroy() first."); return; } if (!config || !config.webhook) { throw new Error("[Tyrekick] init: `webhook` is required."); } if (!config.appVersion) { throw new Error("[Tyrekick] init: `appVersion` is required."); } const cfg: Resolved = { webhook: config.webhook, appVersion: config.appVersion, projectName: config.projectName || document.title || "Prototype", position: config.position === "bottom-left" ? "bottom-left" : "bottom-right", accent: config.accent || "#FFC53D", theme: config.theme === "light" || config.theme === "dark" ? config.theme : "auto", branding: config.branding !== false, fieldName: !config.fields || config.fields.name !== false, transport: config.transport === "discord" ? "discord" : "json", persist: config.persist !== false, captureErrors: config.captureErrors !== false, reviewKey: typeof config.reviewKey === "string" && config.reviewKey ? config.reviewKey : null, }; const host = document.createElement("div"); host.setAttribute("data-tyrekick", ""); host.style.cssText = "position:absolute;top:0;left:0;width:0;height:0"; const root = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = styles(cfg.accent); root.appendChild(style); startTheme(host, cfg.theme); rt = { cfg, root, host, sessionId: uuid(), pins: [], overlay: null as unknown as Overlay, panel: null as unknown as Panel, drawer: null as unknown as Drawer, trigger: null as unknown as HTMLButtonElement, pendingDraft: null, savePins, saveDraft, clearDraft, saveReceipts, checkReceipts, fetchShared, }; rt.overlay = createOverlay(rt); rt.panel = createPanel(rt); rt.trigger = createTrigger(rt); root.appendChild(rt.trigger); rt.drawer = createDrawer(rt); (document.body || document.documentElement).appendChild(host); startErrors(cfg.captureErrors); if (cfg.persist) restore(); rt.drawer.refresh(); checkReceipts(); fetchShared(); // Pins re-attach to their anchored element (selector + in-element fraction) // so they survive viewport/layout changes; raw doc coords are the fallback. if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", reprojectPins, { once: true }); } else { reprojectPins(); } resizeCb = () => { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(reprojectPins, 150); }; window.addEventListener("resize", resizeCb); // Follow SPA route changes so per-route pins don't leak across views. lastPath = location.pathname; startNav(); } export function destroy(): void { if (!rt) return; stopTheme(); stopNav(); lastPath = ""; if (resizeCb) { window.removeEventListener("resize", resizeCb); resizeCb = null; } if (resizeTimer) { clearTimeout(resizeTimer); resizeTimer = null; } receiptWatch.clear(); lastReceiptCheck = 0; lastSharedFetch = 0; try { stopErrors(); } catch { /* ignore */ } try { rt.drawer.destroy(); } catch { /* ignore */ } try { rt.panel.destroy(); } catch { /* ignore */ } try { rt.overlay.destroy(); } catch { /* ignore */ } try { rt.host.remove(); } catch { /* ignore */ } rt = null; } /* ---- session persistence (localStorage, gated by cfg.persist) ---- */ function pinsKey(): string { return "tyrekick:pins:" + location.pathname; } function draftKey(): string { return "tyrekick:draft:" + location.pathname; } function receiptsKey(): string { return "tyrekick:receipts:" + location.pathname; } function savePins(): void { if (!rt || !rt.cfg.persist) return; try { const data = rt.pins // localStorage is only for unsent work that may need recovery after a reload. .filter((p) => p.status === "failed") .map((p) => ({ i: p.id, n: p.n, x: p.docX, y: p.docY, s: p.status, a: p.anchor, b: p.body, r: p.reviewer, t: p.at, ri: p.replyToId, ex: p.fx, ey: p.fy, })); if (data.length) { localStorage.setItem(pinsKey(), JSON.stringify(data)); } else { localStorage.removeItem(pinsKey()); } } catch { /* storage unavailable/full — non-fatal */ } } /** * Persist delivered-comment receipts so a returning reviewer's pins can show * what happened to them. Worker transport only (Discord has no read-back) and * persist-gated like all storage. Newest-first, capped in count and age. */ function saveReceipts(): void { if (!rt || !rt.cfg.persist || rt.cfg.transport !== "json") return; try { const cutoff = Date.now() - RECEIPT_MAX_AGE_MS; const data = rt.pins // `!p.foreign` is load-bearing: other reviewers' pins live on the server // and are re-fetched every load. Persisting them here would resurrect // them locally as this reviewer's own comments — and keep resurrecting // them after they were declined and withdrawn from the shared view. .filter((p) => !p.foreign && p.deliveredId !== null && p.status === "sent") .filter((p) => { const ts = Date.parse(p.at); return !Number.isFinite(ts) || ts >= cutoff; }) .sort((a, b) => (a.at < b.at ? 1 : -1)) .slice(0, RECEIPT_MAX_COUNT) .map((p) => ({ d: p.deliveredId, i: p.id, n: p.n, x: p.docX, y: p.docY, a: p.anchor, b: p.body, r: p.reviewer, t: p.at, ri: p.replyToId, ex: p.fx, ey: p.fy, })); if (data.length) { localStorage.setItem(receiptsKey(), JSON.stringify(data)); } else { localStorage.removeItem(receiptsKey()); } } catch { /* storage unavailable/full — non-fatal */ } } function restoreReceipts(): void { if (!rt) return; try { const raw = localStorage.getItem(receiptsKey()); if (!raw) return; const arr = JSON.parse(raw) as Array<{ d?: string; i?: string; n?: number; x?: number; y?: number; a?: Pin["anchor"]; b?: string; r?: string | null; t?: string; ri?: string | null; ex?: number | null; ey?: number | null; }>; if (!Array.isArray(arr)) return; const cutoff = Date.now() - RECEIPT_MAX_AGE_MS; for (const d of arr) { if (!d || typeof d.d !== "string") continue; const ts = Date.parse(d.t || ""); if (Number.isFinite(ts) && ts < cutoff) continue; receiptWatch.add(d.d); rt.pins.push({ id: typeof d.i === "string" ? d.i : uuid(), n: typeof d.n === "number" ? d.n : 0, // Not persisted: only unsent work is stored, and unsent work has no // assigned number yet. Relearned from /shared once it is delivered. serverN: null, docX: typeof d.x === "number" ? d.x : 0, docY: typeof d.y === "number" ? d.y : 0, clientX: 0, clientY: 0, status: "sent", replyToId: typeof d.ri === "string" ? d.ri : null, fx: typeof d.ex === "number" ? d.ex : null, fy: typeof d.ey === "number" ? d.ey : null, deliveredId: d.d, receipt: null, aiReply: null, foreign: false, anchor: upgradeAnchor(d.a), body: typeof d.b === "string" ? d.b : "", reviewer: typeof d.r === "string" ? d.r : null, at: typeof d.t === "string" ? d.t : "", el: null, }); } } catch { /* ignore corrupt data */ } } /** * Ask the worker what happened to restored delivered comments (the /receipts * capability route). Fire-and-forget, throttled, and silent on every failure — * a destination without the route simply never turns pins green. */ function checkReceipts(): void { if (!rt || rt.cfg.transport !== "json") return; const now = Date.now(); if (now - lastReceiptCheck < RECEIPT_POLL_MIN_MS) return; const ids = rt.pins .map((p) => p.deliveredId) .filter((id): id is string => id !== null && receiptWatch.has(id)) .slice(0, RECEIPT_MAX_COUNT); if (!ids.length) return; lastReceiptCheck = now; const base = rt.cfg.webhook.replace(/\/feedback\/?$/, ""); void fetch(base + "/receipts?ids=" + ids.join(",")) .then((res) => (res && res.ok ? res.json() : null)) .then((data: unknown) => { const body = data as { ok?: unknown; receipts?: unknown } | null; if (!rt || !body || body.ok !== true || !Array.isArray(body.receipts)) return; let changed = false; for (const r of body.receipts as Array<{ id?: unknown; status?: unknown; resolved_at?: unknown; resolution_note?: unknown; ai_reply?: unknown; }>) { if (!r || typeof r.id !== "string") continue; const pin = rt.pins.find((p) => p.deliveredId === r.id); if (!pin) continue; // AI acknowledgement is a THREAD MESSAGE, not a status change: apply it // whatever the status ("open" included) and never let it touch // pin.receipt or the pin's open/closed state. const aiReply = typeof r.ai_reply === "string" ? r.ai_reply : null; if (pin.aiReply !== aiReply) { pin.aiReply = aiReply; changed = true; } if (r.status !== "resolved" && r.status !== "declined") continue; const note = typeof r.resolution_note === "string" ? r.resolution_note : null; const at = typeof r.resolved_at === "string" ? r.resolved_at : null; if (pin.receipt && pin.receipt.status === r.status && pin.receipt.note === note) continue; pin.receipt = { status: r.status, note, at }; changed = true; } if (changed && rt.drawer) rt.drawer.refresh(); // refresh also re-syncs pins }) .catch(() => { /* silent by contract — never a console error */ }); } /* ---- shared review (other reviewers' pins; opt-in via cfg.reviewKey) ---- */ /** One pin as the worker's /shared projection sends it. All fields optional * on the wire — an older or hand-rolled destination may send less. */ interface SharedPin { id?: unknown; /** Destination-assigned page number. Absent from pre-0.6 workers. */ n?: unknown; created_at?: unknown; body?: unknown; reviewer_name?: unknown; status?: unknown; resolution_note?: unknown; resolved_at?: unknown; anchor?: unknown; } /** * Order pins so every reviewer numbers them the same way: delivered comments * chronologically by submission time, then this browser's unsent work (which * has no submission time yet and exists only here) in its own order. * * Only ever called in shared mode. Single-reviewer numbering keeps its historic * insertion order so existing installs renumber nothing on upgrade. */ function sortPinsChronologically(pins: Pin[]): void { const order = new Map(); pins.forEach((p, i) => order.set(p, i)); pins.sort((a, b) => { const at = a.at || ""; const bt = b.at || ""; // Unsent work (no timestamp) always sorts after everything delivered. if (!at && bt) return 1; if (at && !bt) return -1; if (at !== bt) return at < bt ? -1 : 1; // Same timestamp (or both unsent): keep the order we already had, so // numbering never flickers between polls. return (order.get(a) ?? 0) - (order.get(b) ?? 0); }); } /** * Fold the worker's shared view into the pin list. * * Rules that keep this safe: * - A shared pin that matches one of OUR delivered ids is skipped — your own * comment must not come back as a second, foreign copy of itself. * - Existing foreign pins are updated in place (status/note), never duplicated. * - Foreign pins that vanish from the view (declined, or aged past the cap) * are dropped, so declining spam removes it from everyone's page. */ function mergeShared(list: SharedPin[]): boolean { if (!rt) return false; const mine = new Set( rt.pins.filter((p) => !p.foreign && p.deliveredId).map((p) => p.deliveredId as string), ); const seen = new Set(); let changed = false; for (const s of list) { if (!s || typeof s.id !== "string") continue; const serverN = typeof s.n === "number" && s.n > 0 ? s.n : null; if (mine.has(s.id)) { // Our own comment, already on the page — but this is the only place we // ever learn the number the destination gave it. Stamping it here is what // makes our numbering agree with everyone else's; skipping outright is // how a reviewer ends up writing "Re #3" about a comment that is #6 to // the person reading it. const own = rt.pins.find((p) => !p.foreign && p.deliveredId === s.id); if (own && own.serverN !== serverN) { own.serverN = serverN; changed = true; } continue; } const status = s.status; // "declined" never reaches us (the worker withholds it), but an older // worker might send it — treat it the same way and skip. if (status === "declined") continue; seen.add(s.id); const body = typeof s.body === "string" ? s.body : ""; if (!body) continue; const note = typeof s.resolution_note === "string" ? s.resolution_note : null; const at = typeof s.created_at === "string" ? s.created_at : ""; const receipt: Pin["receipt"] = status === "resolved" ? { status: "resolved", note, at: typeof s.resolved_at === "string" ? s.resolved_at : null, } : null; const existing = rt.pins.find((p) => p.foreign && p.deliveredId === s.id); if (existing) { const sameReceipt = (existing.receipt === null && receipt === null) || (existing.receipt !== null && receipt !== null && existing.receipt.status === receipt.status && existing.receipt.note === receipt.note); if (!sameReceipt) { existing.receipt = receipt; changed = true; } continue; } const anchor = upgradeAnchor(s.anchor as Pin["anchor"] | undefined); // Place it from the document percentages the other reviewer recorded. When // their click also carried a selector we hand reprojectPins a centre // fraction, so the pin re-anchors to the live element like a local one — // the exact in-element offset isn't recoverable (it is not in the payload), // and the element's centre is both stable and honest about that. const doc = document.documentElement; const docW = doc ? doc.scrollWidth : 0; const docH = doc ? doc.scrollHeight : 0; rt.pins.push({ // The server id doubles as the local pin id (both are UUIDs, so they // cannot collide). That makes a foreign pin's identity stable across // reloads, which is what lets a local reply stay attached to the comment // it was answering instead of orphaning into a root. id: s.id, n: 0, // assigned by the renumber pass below serverN, docX: (Number(anchor.x_pct) / 100) * docW, docY: (Number(anchor.y_pct) / 100) * docH, clientX: 0, clientY: 0, status: "sent", replyToId: null, // threads are per-destination; a foreign pin is a root fx: anchor.selector ? 0.5 : null, fy: anchor.selector ? 0.5 : null, deliveredId: s.id, receipt, aiReply: null, foreign: true, anchor, body, reviewer: typeof s.reviewer_name === "string" ? s.reviewer_name : null, at, el: null, }); changed = true; } // Drop foreign pins the view no longer returns (declined or aged out). for (let i = rt.pins.length - 1; i >= 0; i -= 1) { const p = rt.pins[i]; if (!p.foreign) continue; if (p.deliveredId && seen.has(p.deliveredId)) continue; if (p.el) p.el.remove(); rt.pins.splice(i, 1); changed = true; } if (changed) { sortPinsChronologically(rt.pins); normalizeRestoredPins(rt.pins); } return changed; } /** * Ask the worker for every reviewer's pins on this project + route. Same * posture as checkReceipts: throttled, fire-and-forget, and silent on every * failure — a destination without /shared (or with the feature switched off) * simply never shows foreign pins, with no console noise ever. */ function fetchShared(force?: boolean): void { if (!rt || !rt.cfg.reviewKey || rt.cfg.transport !== "json") return; if (sharedInFlight) return; const now = Date.now(); if (!force && now - lastSharedFetch < SHARED_POLL_MIN_MS) return; lastSharedFetch = now; sharedInFlight = true; const base = rt.cfg.webhook.replace(/\/feedback\/?$/, ""); // Scope by PATHNAME, not the full route: pins are per-pathname everywhere // else in the widget (storage keys, SPA reset), and reviewers reach the same // page by links that differ only in query string (?utm_source=…, ?ref=…). // Sending the full route would silently split one conversation into several. // It also keeps the page's own query string — which can carry link secrets — // out of the request. const qs = "?project=" + encodeURIComponent(rt.cfg.projectName) + "&route=" + encodeURIComponent(location.pathname); void fetch(base + "/shared" + qs, { headers: { "X-Tyrekick-Review-Key": rt.cfg.reviewKey }, }) .then((res) => (res && res.ok ? res.json() : null)) .then((data: unknown) => { const body = data as { ok?: unknown; pins?: unknown } | null; if (!rt || !body || body.ok !== true || !Array.isArray(body.pins)) return; if (mergeShared(body.pins as SharedPin[])) { rt.drawer.refresh(); // refresh also re-syncs pins reprojectPins(); // foreign pins arrive with no coords — place them now } }) .catch(() => { /* silent by contract — never a console error */ }) .then(() => { sharedInFlight = false; }); } function saveDraft(d: Draft): void { if (!rt) return; rt.pendingDraft = d; if (!rt.cfg.persist) return; try { localStorage.setItem(draftKey(), JSON.stringify(d)); } catch { /* ignore */ } } function clearDraft(): void { if (!rt) return; rt.pendingDraft = null; if (!rt.cfg.persist) return; try { localStorage.removeItem(draftKey()); } catch { /* ignore */ } } function restore(): void { if (!rt) return; // Delivered comments first (chronologically they came first), then unsent // work; a single renumber pass at the end covers both. restoreReceipts(); try { const raw = localStorage.getItem(pinsKey()); if (raw) { const arr = JSON.parse(raw) as Array<{ i?: string; n: number; x: number; y: number; s: Pin["status"]; a: Pin["anchor"]; b?: string; r?: string | null; t?: string; ri?: string | null; re?: number | null; ex?: number | null; ey?: number | null; }>; if (Array.isArray(arr)) { const idsByLegacyNumber = new Map(); const restored = arr .filter((d) => d && d.s === "failed") .map((d) => { const id = typeof d.i === "string" ? d.i : uuid(); idsByLegacyNumber.set(d.n, id); return { d, id, body: typeof d.b === "string" ? d.b : "" }; }); for (const { d, id, body } of restored) { const legacyRootNumber = typeof d.re === "number" ? d.re : legacyReplyTo(body, d.n); rt.pins.push({ id, n: d.n, serverN: null, docX: d.x, docY: d.y, clientX: 0, clientY: 0, status: "failed", replyToId: typeof d.ri === "string" ? d.ri : legacyRootNumber !== null ? (idsByLegacyNumber.get(legacyRootNumber) ?? null) : null, fx: typeof d.ex === "number" ? d.ex : null, fy: typeof d.ey === "number" ? d.ey : null, deliveredId: null, receipt: null, aiReply: null, foreign: false, anchor: upgradeAnchor(d.a), body, reviewer: typeof d.r === "string" ? d.r : null, at: typeof d.t === "string" ? d.t : "", el: null, }); } } } } catch { /* ignore corrupt data */ } normalizeRestoredPins(rt.pins); try { const raw = localStorage.getItem(draftKey()); if (raw) rt.pendingDraft = JSON.parse(raw) as Draft; } catch { /* ignore */ } } /** * Follow-ups saved by builds that predate Pin.replyToId were full pins whose * only linkage was the "Re #N:" body prefix — recover it from there. */ function legacyReplyTo(body: string, n: number): number | null { const m = /^Re #(\d+):/.exec(body); const pn = m ? parseInt(m[1], 10) : NaN; return Number.isInteger(pn) && pn < n ? pn : null; } function normalizeRestoredPins(pins: Pin[]): void { // Mirrors renumberPins: assigned numbers win, counted ones start above them. let n = 0; for (const p of pins) { if (p.replyToId === null && p.serverN !== null && p.serverN > n) n = p.serverN; } const roots = new Map(); for (const p of pins) { if (p.replyToId !== null) continue; if (p.serverN !== null) { p.n = p.serverN; } else { n += 1; p.n = n; } roots.set(p.id, p); } for (const p of pins) { if (p.replyToId === null) continue; const root = roots.get(p.replyToId); if (root) { p.n = root.n; } else { n += 1; p.n = n; } } } /** * Pins persisted by a schema-v1 build lack anchor.element / anchor.context. * Backfill them so payload construction never breaks after an upgrade. */ function upgradeAnchor(a: Pin["anchor"] | undefined): Pin["anchor"] { const p = (a || {}) as Partial; return { x_pct: typeof p.x_pct === "number" ? p.x_pct : 0, y_pct: typeof p.y_pct === "number" ? p.y_pct : 0, selector: typeof p.selector === "string" ? p.selector : null, viewport: p.viewport || { w: 0, h: 0 }, element: p.element || null, context: p.context || { heading: null, landmark: null }, }; }