1// 1800.show â one room, ascii webcams, chat. 2// Everyone drops into /main. Up to 6 peers (mesh cap); 4Ã4 tile grid. 3// Phone verify via Twilio. Google/Apple sign-in stubs are visible in the 4// modal but disabled until wired. 5 6import { startLiveRender } from "/ascii.js"; 7import { Mesh } from "/peer.js"; 8import { wantsCamera, acquireCamera, releaseCamera, sharedStream } from "/cam-store.js"; 9import { makeFaceCropper } from "/face.js"; 10import { renderSymbolicFace } from "/face-symbolic.js"; 11 12const ROOM = "main"; 13 14const logEl = document.getElementById("log"); 15const camsEl = document.getElementById("cams"); 16const camsTopEl = document.getElementById("camsTop"); 17// camStart is re-rendered by renderMeTile on signed-in/out transitions, so 18// rely on live lookup + delegation instead of a stashed DOM ref. 19function getCamStartBtn() { return document.getElementById("camStart"); } 20const selfCanvas = document.getElementById("selfCanvas"); 21const composer = document.getElementById("composer"); 22const msgInput = document.getElementById("msg"); 23const micBtn = document.getElementById("micBtn"); 24const picBtn = document.getElementById("picBtn"); 25const picInput = document.getElementById("picInput"); 26const sendBtn = document.getElementById("sendBtn"); 27// Send button lives outside the form (it's in the right sidebar now so the 28// textarea can span full width). Wire it to trigger the same submit flow. 29// submitComposer: single entry point for "send the current textarea". The 30// composer is a <div> now (not a form) so iOS Safari's form-navigation 31// keyboard bar doesn't show up â meaning composer.requestSubmit() doesn't 32// exist either. Both Enter and the sidebar send button call this instead. 33async function submitComposer() { 34 const v = msgInput.value.trim(); if (!v) return; 35 msgInput.value = ""; 36 resizeComposer(); 37 slashPopup.hidden = true; 38 if (v.startsWith("/")) return handleSlash(v); 39 if (!account) { openVerifyModal(); return; } 40 const out = { t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), text: v }; 41 renderChat(out); 42 if (!ws) connectSignaling(); 43 const trySend = () => { 44 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out)); 45 else setTimeout(trySend, 100); 46 }; 47 trySend(); 48} 49sendBtn?.addEventListener("click", submitComposer); 50const slashPopup = document.getElementById("slashPopup"); 51const meTileEl = document.getElementById("meTile"); 52const meTileBody = document.getElementById("meTileBody"); 53const meProfileBtn = document.getElementById("meProfileBtn"); 54// Persistent "open profile" affordance in the corner of the me-tile so the 55// panel is always reachable even while the cam is on. 56meProfileBtn?.addEventListener("click", (e) => { 57 e.stopPropagation(); 58 if (account) openAvatarPicker(); 59}); 60const modal = document.getElementById("verifyModal"); 61const modalClose = document.getElementById("modalClose"); 62 63let account = null; 64let stopRender = null; 65let mesh = null, ws = null, myPeerId = null; 66 67function esc(s) { return String(s).replace(/[&<>]/g, c => ({ "&":"&", "<":"<", ">":">" }[c])); } 68 69// Shared AudioContext for every level-meter on the page. Created lazily on 70// the first call, which runs inside a user gesture (camera start click), 71// satisfying the autoplay policy. 72let audioCtx = null; 73function makeLevelMeter(stream) { 74 try { 75 if (!stream?.getAudioTracks?.().length) return () => 0; 76 if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 77 // Resume in case the browser auto-suspended the context (e.g. tab 78 // was backgrounded). Suspended context â getByteTimeDomainData returns 79 // flat 128 â RMS=0 â mouth never moves. 80 if (audioCtx.state === "suspended") audioCtx.resume(); 81 const src = audioCtx.createMediaStreamSource(stream); 82 const analyser = audioCtx.createAnalyser(); 83 analyser.fftSize = 512; 84 analyser.smoothingTimeConstant = 0.3; 85 src.connect(analyser); 86 const buf = new Uint8Array(analyser.fftSize); 87 let smoothed = 0; 88 return () => { 89 analyser.getByteTimeDomainData(buf); 90 let sum = 0; 91 for (let i = 0; i < buf.length; i++) { 92 const v = (buf[i] - 128) / 128; 93 sum += v * v; 94 } 95 const rms = Math.sqrt(sum / buf.length); 96 smoothed = smoothed * 0.5 + rms * 0.5; 97 return smoothed; 98 }; 99 } catch (e) { 100 console.warn("[18:audio] level meter failed", e); 101 return () => 0; 102 } 103} 104// Avatar urls can be still images (.png/.webp) OR looping videos 105// (.mp4 from /u/pvid/<id>.mp4 or /u/gen/...mp4). Render a <video> for 106// video extensions, autoplay/muted/loop/playsinline so it acts as an 107// animated thumbnail. Returns an HTML fragment with the requested 108// container class, sized via CSS like the still-image variant. 109function isVideoAvatar(url) { 110 return /\.(mp4|webm|mov)(\?|$)/i.test(url || ""); 111} 112function avatarHtml(url, klass = "") { 113 if (!url) return ""; 114 if (isVideoAvatar(url)) { 115 return `<video class="${esc(klass)} avatar-video" data-autoloop="1" src="${esc(url)}" muted loop playsinline preload="metadata"></video>`; 116 } 117 return `<div class="${esc(klass)}" style="background-image:url(${esc(url)})"></div>`; 118} 119 120// Global pause-when-offscreen observer for any <video data-autoloop> 121// elements (avatars, suno videos, chat attachments). Decoding 1000+ 122// videos simultaneously kills scroll fps; only the videos visible in 123// the viewport keep playing, the rest pause and don't decode frames. 124const videoVisibility = new IntersectionObserver((entries) => { 125 for (const e of entries) { 126 const v = e.target; 127 if (e.isIntersecting) { 128 v.play?.().catch(() => {}); 129 } else { 130 try { v.pause?.(); } catch {} 131 } 132 } 133}, { rootMargin: "100px" }); 134function watchAutoloopVideo(v) { try { videoVisibility.observe(v); } catch {} } 135// Mutation observer scans newly-added video[data-autoloop] elements 136// anywhere in the document and registers them with the visibility 137// observer. Fires once at boot for any pre-existing nodes too. 138new MutationObserver((muts) => { 139 for (const m of muts) for (const n of m.addedNodes) { 140 if (n.nodeType !== 1) continue; 141 if (n.matches?.("video[data-autoloop]")) watchAutoloopVideo(n); 142 n.querySelectorAll?.("video[data-autoloop]").forEach(watchAutoloopVideo); 143 } 144}).observe(document.body, { childList: true, subtree: true }); 145document.querySelectorAll("video[data-autoloop]").forEach(watchAutoloopVideo); 146 147function linkify(escaped) { 148 return escaped.replace(/(https?:\/\/[^\s<]+)/g, (url) => { 149 try { 150 const u = new URL(url); 151 const host = u.hostname.replace(/^www\./, ""); 152 return `<a class="url-btn" href="${esc(url)}" target="_blank" rel="noopener noreferrer">[${esc(host)}]</a>`; 153 } catch { return url; } 154 }); 155} 156 157// Stable todo id: sha1(msg_id:line_index), first 16 hex chars. 158// Matches the server's todoId() in todos.js so state lines up. 159async function todoIdFor(msgId, lineIndex) { 160 const enc = new TextEncoder().encode(`${msgId}:${lineIndex}`); 161 const h = await crypto.subtle.digest("SHA-1", enc); 162 return Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16); 163} 164 165// Parse a message body into HTML: 166// - lines beginning "- <text>" become checkbox todos with data-todo-id 167// - everything else passes through linkify/esc 168// - raw \n stays in the output; .msg .body has white-space: pre-wrap 169// so the browser renders the breaks. Saves tokens (bots don't need 170// to emit <br>) and avoids HTML-escaping the linebreaks. 171// Async because we need subtle.digest for the ids.
172async function renderMessageBody(msgId, text) { 173 if (!text) return ""; 174 const lines = String(text).split(/\r?\n/); 175 const out = []; 176 for (let i = 0; i < lines.length; i++) { 177 const line = lines[i]; 178 const m = line.match(/^(\s*)-\s+(?!\s)(.+?)\s*$/); 179 if (m && m[2]) { 180 const indent = m[1].replace(/ /g, " "); 181 const id = await todoIdFor(msgId, i); 182 const state = todoState.get(id); 183 const checked = state?.checked ? " checked" : ""; 184 const byAttr = state?.checked_by ? ` title="checked by @${esc(state.checked_by)}"` : ""; 185 out.push(`${indent}<label class="todo-item"${byAttr}>` + 186 `<input type="checkbox" class="todo-check" data-todo-id="${id}"${checked}>` + 187 `<span class="todo-text">${linkify(esc(m[2]))}</span>` + 188 `</label>`); 189 } else { 190 out.push(linkify(esc(line))); 191 } 192 } 193 return out.join("\n"); 194} 195 196// Local mirror of todo state (for renders that arrive before the state 197// snapshot lands or before the WS catches up). Populated by the snapshot 198// fetch below AND by incoming todo-toggle WS messages. 199const todoState = new Map(); // id â { checked, checked_by } 200 201async function refreshTodoState() { 202 try { 203 const r = await fetch("/api/todos/flat"); 204 const j = await r.json(); 205 if (!j.ok) return; 206 todoState.clear(); 207 for (const t of j.todos || []) { 208 if (t.id) todoState.set(t.id, { checked: !!t.checked, checked_by: t.checked_by || null }); 209 } 210 } catch {} 211} 212function applyTodoToggle(id, checked, by) { 213 todoState.set(id, { checked: !!checked, checked_by: by || null }); 214 for (const cb of document.querySelectorAll(`.todo-check[data-todo-id="${id}"]`)) { 215 cb.checked = !!checked; 216 const label = cb.closest(".todo-item"); 217 if (label) { 218 label.title = by ? `checked by @${by}` : ""; 219 label.classList.toggle("done", !!checked); 220 } 221 } 222} 223// Delegated: clicking any todo checkbox POSTs the new state. The WS 224// broadcast that comes back applies the class/title update; the local 225// click already flipped the input itself. 226document.addEventListener("change", async (e) => { 227 const cb = e.target.closest(".todo-check"); 228 if (!cb) return; 229 if (!account) { cb.checked = !cb.checked; openVerifyModal(); return; } 230 const id = cb.dataset.todoId; 231 if (!id) return; 232 try { 233 await fetch(`/api/todos/${encodeURIComponent(id)}/toggle`, { 234 method: "POST", 235 headers: { "Content-Type": "application/json" }, 236 body: JSON.stringify({ checked: cb.checked }), 237 }); 238 } catch {} 239}); 240 241// âââââââââ account âââââââââ 242async function loadAccount() { 243 try { 244 const r = await fetch("/api/account/me"); 245 const j = await r.json(); 246 account = j.account || null; 247 renderMeTile(j); 248 return j; 249 } catch { return { verified: false }; } 250} 251 252// The top-right "me" square always reflects one of three states: 253// signed in + cam on â the self ASCII canvas (handled by startCamera) 254// signed in + cam off â avatar image if set, else username centered 255// not signed in â sign-in button taking the whole square 256function renderMeTile(meInfo) { 257 // Keep selfCanvas in place if cam is on â don't clobber the render. 258 if (selfCanvas && !selfCanvas.hidden && selfCanvas.parentElement === meTileBody) return; 259 meTileBody.innerHTML = ""; 260 if (!account) { 261 meTileBody.innerHTML = ` 262 <button class="cam-start" id="camStart" type="button"> 263 <pre class="happy" aria-hidden="true"> 264 ______ 265/ \\ 266| o o | 267| .. | 268| -- | 269\\______/ 270</pre> 271 <span class="cam-start-text">${meInfo?.verified ? "pick username" : "sign in"}</span> 272 </button>`; 273 meTileBody.querySelector(".cam-start").addEventListener("click", async () => { 274 if (meInfo?.verified) await pickUsername(); 275 else openVerifyModal(); 276 }); 277 updateCamLauncher(); 278 return; 279 } 280 if (account.avatarUrl) { 281 meTileBody.innerHTML = `${avatarHtml(account.avatarUrl, "me-avatar")}
282 <span class="self-name">${esc(account.username)}</span>`; 283 } else { 284 meTileBody.innerHTML = 285 `<span class="self-name">${esc(account.username)}</span>`; 286 } 287 updateCamLauncher(); 288} 289 290// The cam-launcher tile lives outside the me-tile (see index.html), as a 291// second square cell in the cam column. It shows only when the user is 292// signed in AND the self-cam is currently off; a running cam replaces it. 293const camLauncherEl = document.getElementById("camLauncher"); 294function updateCamLauncher() { 295 if (!camLauncherEl) return; 296 const camOn = !!(selfCanvas && !selfCanvas.hidden); 297 camLauncherEl.hidden = !account || camOn; 298} 299camLauncherEl?.addEventListener("click", () => { 300 if (!account) return; 301 startCamera(); 302}); 303// Click the me-tile body (anywhere but on a cam-start button or the profile 304// corner button) â profile panel. Cam-start has its own handler; meProfileBtn 305// is wired above and stops propagation. 306meTileBody.addEventListener("click", (e) => { 307 if (!account) return; 308 if (e.target.closest(".cam-start")) return; 309 // Clicking my own me-tile now opens my public myspace-style 310 // profile (same view everyone else sees). The legacy avatar 311 // picker lives behind the â icon â meProfileBtn above. 312 openProfilePanel(account.username); 313}); 314modalClose.addEventListener("click", () => { modal.hidden = true; }); 315 316// Disable hidden inputs so iOS doesn't show prev/next arrows. 317function setVerifyReachable(reachable) { 318 for (const id of ["verifyPhone", "verifyCode"]) { 319 const el = document.getElementById(id); 320 if (!el) continue;
321 el.tabIndex = reachable ? 0 : -1; 322 el.disabled = !reachable; 323 } 324} 325setVerifyReachable(false); 326new MutationObserver(() => setVerifyReachable(!modal.hidden)) 327 .observe(modal, { attributes: true, attributeFilter: ["hidden"] }); 328 329// âââââââââ verify modal âââââââââ 330const LAST_PHONE_KEY = "18.phone"; 331const verifyPhoneEl = document.getElementById("verifyPhone"); 332const verifyCodeEl = document.getElementById("verifyCode"); 333 334// Open the verify modal and focus the first visible input so users can 335// start typing immediately â less tapping on phones. 336function openVerifyModal() { 337 modal.hidden = false; 338 setTimeout(() => { 339 if (document.getElementById("sendForm").hidden) { 340 verifyCodeEl.focus(); 341 verifyCodeEl.select?.(); 342 } else { 343 verifyPhoneEl.focus(); 344 } 345 }, 60); 346} 347try { 348 const saved = localStorage.getItem(LAST_PHONE_KEY); 349 if (saved) verifyPhoneEl.value = saved; 350} catch {} 351 352document.getElementById("sendForm").addEventListener("submit", async (e) => { 353 e.preventDefault(); 354 const m = document.getElementById("verifyMsg"); m.textContent = "sendingâ¦"; 355 const phone = verifyPhoneEl.value; 356 const r = await fetch("/api/verify/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone }) }); 357 const j = await r.json(); 358 if (!r.ok) { m.textContent = "error: " + (j.error || "unknown"); return; } 359 try { localStorage.setItem(LAST_PHONE_KEY, phone); } catch {} 360 m.textContent = `code sent to ${phone}`; 361 document.getElementById("sendForm").hidden = true; 362 document.getElementById("checkForm").hidden = false; 363 setTimeout(() => verifyCodeEl.focus(), 50); 364 365 if ("OTPCredential" in window) { 366 const ac = new AbortController(); 367 document.getElementById("checkForm").addEventListener("submit", () => ac.abort(), { once: true }); 368 try { 369 const otp = await navigator.credentials.get({ otp: { transport: ["sms"] }, signal: ac.signal }); 370 if (otp?.code) { 371 verifyCodeEl.value = otp.code; 372 document.getElementById("checkForm").dispatchEvent(new Event("submit", { cancelable: true })); 373 } 374 } catch {} 375 } 376}); 377document.getElementById("checkForm").addEventListener("submit", async (e) => { 378 e.preventDefault(); 379 const m = document.getElementById("verifyMsg"); m.textContent = "checkingâ¦"; 380 const r = await fetch("/api/verify/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: verifyPhoneEl.value, code: verifyCodeEl.value }) }); 381 if (!r.ok) { m.textContent = "invalid code"; return; } 382 m.textContent = "verified"; modal.hidden = true; 383 await loadAccount(); 384 if (!account) await pickUsername(); 385 // Reset for next time 386 document.getElementById("sendForm").hidden = false; 387 document.getElementById("checkForm").hidden = true; 388 verifyCodeEl.value = ""; 389 // If the user got here from the Google-link flow, finish the link 390 // now that we have a valid session. 391 await maybeFinishGoogleLink(); 392 await maybeFinishParasceneLink(); 393 // Reconnect WS with new identity so chat has a stable "from". 394 reconnectWs(); 395}); 396async function pickUsername() { 397 while (true) { 398 const u = prompt("pick a username (3â20, lowercase letters/numbers/_):"); 399 if (!u) continue; 400 const r = await fetch("/api/account/claim", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u }) }); 401 const j = await r.json(); 402 if (r.ok) { account = j.account; await loadAccount(); return; } 403 alert("username error: " + (j.error || "unknown")); 404 } 405} 406 407// âââââââââ google sign-in / link modal âââââââââ 408// 409// The OAuth callback sets `?google_link_pending=1` when an unknown 410// Google identity arrives without a session. We show a 2-button modal: 411// - "create new account" with a username input â POST create-account 412// - "sign in with phone to link" â opens the existing verify modal, 413// then POSTs finish-link once the user is signed in. 414// 415// Other URL flags handled here:
416// ?signed_in=1 â Google login worked, just clean the URL 417// ?google_linked=1 â added Google to existing account, brief msg 418// ?auth_error=⦠â surface an error in the verify modal 419function cleanAuthQueryString() { 420 const u = new URL(location.href); 421 let dirty = false; 422 for (const k of [ 423 "signed_in", "google_link_pending", "google_linked", 424 "parascene_link_pending", "parascene_linked", "auth_error", 425 ]) { 426 if (u.searchParams.has(k)) { u.searchParams.delete(k); dirty = true; } 427 } 428 if (dirty) history.replaceState(null, "", u.toString()); 429} 430 431async function handleAuthLandings() { 432 const params = new URLSearchParams(location.search); 433 if (params.get("auth_error")) { 434 const msg = document.getElementById("verifyMsg"); 435 if (msg) msg.textContent = "sign-in error: " + params.get("auth_error"); 436 cleanAuthQueryString(); 437 return; 438 } 439 if (params.get("google_linked") === "1" || params.get("parascene_linked") === "1") { 440 const msg = document.getElementById("verifyMsg"); 441 if (msg) msg.textContent = params.get("parascene_linked") === "1" ? "parascene linked." : "google linked."; 442 cleanAuthQueryString(); 443 await loadAccount(); 444 return; 445 } 446 if (params.get("signed_in") === "1") { 447 cleanAuthQueryString(); 448 await loadAccount(); 449 reconnectWs(); 450 return; 451 } 452 if (params.get("google_link_pending") === "1") { 453 cleanAuthQueryString(); 454 openGoogleLinkModal(); 455 return; 456 } 457 if (params.get("parascene_link_pending") === "1") { 458 cleanAuthQueryString(); 459 openParasceneLinkModal(); 460 } 461} 462 463function openGoogleLinkModal() { 464 const m = document.getElementById("googleLinkModal"); 465 if (m) m.hidden = false; 466} 467function closeGoogleLinkModal() { 468 const m = document.getElementById("googleLinkModal"); 469 if (m) m.hidden = true; 470} 471function openParasceneLinkModal(suggestedUsername) { 472 const m = document.getElementById("parasceneLinkModal"); 473 if (!m) return; 474 m.hidden = false; 475 if (suggestedUsername) { 476 const inp = document.getElementById("parasceneNewUsername"); 477 if (inp && !inp.value) { 478 // Sanitize: lowercase, drop non-[a-z0-9_], trim to 20 chars, 479 // ensure leading char matches [a-z]. Caller may pass a 480 // display name that won't pass the form pattern as-is. 481 const cleaned = String(suggestedUsername).toLowerCase() 482 .replace(/[^a-z0-9_]/g, "").replace(/^[^a-z]+/, "").slice(0, 20); 483 if (cleaned.length >= 3) inp.value = cleaned; 484 } 485 } 486} 487function closeParasceneLinkModal() { 488 const m = document.getElementById("parasceneLinkModal"); 489 if (m) m.hidden = true; 490} 491 492document.getElementById("googleLinkClose")?.addEventListener("click", closeGoogleLinkModal); 493document.getElementById("googleNewForm")?.addEventListener("submit", async (e) => { 494 e.preventDefault(); 495 const u = document.getElementById("googleNewUsername").value.trim(); 496 if (!u) return; 497 const r = await fetch("/api/auth/google/create-account", { 498 method: "POST", 499 headers: { "Content-Type": "application/json" }, 500 body: JSON.stringify({ username: u }), 501 }); 502 const j = await r.json().catch(() => ({})); 503 if (!r.ok) { alert("couldn't create account: " + (j.error || r.status)); return; } 504 closeGoogleLinkModal(); 505 await loadAccount(); 506 reconnectWs(); 507}); 508document.getElementById("googleLinkPhone")?.addEventListener("click", async () => { 509 closeGoogleLinkModal(); 510 // Stash a flag so once the verify modal succeeds, we POST finish-link. 511 window.__googleLinkAfterVerify = true; 512 openVerifyModal(); 513}); 514// After phone verify succeeds, if the user came from the Google link 515// flow, finish the link by attaching the pending Google identity to 516// the freshly-authenticated account. 517async function maybeFinishGoogleLink() { 518 if (!window.__googleLinkAfterVerify) return; 519 window.__googleLinkAfterVerify = false; 520 try { 521 const r = await fetch("/api/auth/google/finish-link", { method: "POST" }); 522 if (r.ok) console.log("[google] linked to existing account"); 523 else console.warn("[google/finish-link]", await r.text()); 524 } catch (e) { 525 console.warn("[google/finish-link]", e.message); 526 } 527} 528 529// âââââââââ parascene sign-in / link modal âââââââââ 530// 531// Same shape as the Google flow, but the user starts via a popup 532// window so they never visually leave slopstation. The popup hosts 533// parascene's hosted /auth page, which has signup AND login tabs â 534// so brand-new users can create a parascene account in the popup. 535// 536// On success the callback (server-side) returns a tiny HTML page 537// that postMessages back here and closes itself. We listen for 538// {type:"parascene-auth"} messages and route by .result: 539// signed_in â reload account, reconnect ws 540// linked â already-authed user added parascene to their acct 541// link_pending â open the link/create-account modal 542// error â surface in the verify modal's msg area 543function startParasceneSignIn() { 544 const url = "/api/auth/parascene?popup=1"; 545 const w = 480, h = 720;
546 const left = Math.max(0, (screen.availWidth - w) / 2 + (screen.availLeft || 0)); 547 const top = Math.max(0, (screen.availHeight - h) / 2 + (screen.availTop || 0)); 548 const popup = window.open(url, "parascene-auth", 549 `width=${w},height=${h},left=${Math.round(left)},top=${Math.round(top)},menubar=no,toolbar=no,location=no,status=no`); 550 if (!popup) { 551 // Popup blocked â fall back to same-tab redirect. 552 location.href = "/api/auth/parascene"; 553 } 554} 555window.addEventListener("message", async (ev) => { 556 if (ev.origin !== location.origin) return; 557 const d = ev.data; 558 if (!d || d.type !== "parascene-auth") return; 559 if (d.result === "signed_in") { 560 const m = document.getElementById("verifyModal"); if (m) m.hidden = true; 561 await loadAccount(); 562 reconnectWs(); 563 } else if (d.result === "linked") { 564 const msg = document.getElementById("verifyMsg"); 565 if (msg) msg.textContent = "parascene linked."; 566 await loadAccount(); 567 } else if (d.result === "link_pending") { 568 const m = document.getElementById("verifyModal"); if (m) m.hidden = true; 569 openParasceneLinkModal(d.suggestedUsername); 570 } else if (d.result === "error") { 571 const msg = document.getElementById("verifyMsg"); 572 if (msg) msg.textContent = "parascene sign-in error: " + (d.error || "unknown"); 573 } 574}); 575document.getElementById("parasceneSignInBtn")?.addEventListener("click", startParasceneSignIn); 576document.getElementById("parasceneLinkClose")?.addEventListener("click", closeParasceneLinkModal); 577document.getElementById("parasceneNewForm")?.addEventListener("submit", async (e) => { 578 e.preventDefault(); 579 const u = document.getElementById("parasceneNewUsername").value.trim(); 580 if (!u) return; 581 const r = await fetch("/api/auth/parascene/create-account", { 582 method: "POST", 583 headers: { "Content-Type": "application/json" }, 584 body: JSON.stringify({ username: u }), 585 }); 586 const j = await r.json().catch(() => ({})); 587 if (!r.ok) { alert("couldn't create account: " + (j.error || r.status)); return; } 588 closeParasceneLinkModal(); 589 await loadAccount(); 590 reconnectWs(); 591}); 592document.getElementById("parasceneLinkPhone")?.addEventListener("click", () => { 593 closeParasceneLinkModal(); 594 window.__parasceneLinkAfterVerify = true; 595 openVerifyModal(); 596}); 597async function maybeFinishParasceneLink() { 598 if (!window.__parasceneLinkAfterVerify) return; 599 window.__parasceneLinkAfterVerify = false; 600 try { 601 const r = await fetch("/api/auth/parascene/finish-link", { method: "POST" }); 602 if (r.ok) console.log("[parascene] linked to existing account"); 603 else console.warn("[parascene/finish-link]", await r.text()); 604 } catch (e) { 605 console.warn("[parascene/finish-link]", e.message); 606 } 607} 608 609// âââââââââ camera âââââââââ 610async function startCamera() { 611 console.log("[18:cam] startCamera begin", { ua: navigator.userAgent, hasFaceDetector: "FaceDetector" in window }); 612 const me = await loadAccount(); 613 console.log("[18:cam] account", { verified: me.verified, account: !!me.account }); 614 if (!me.verified) { openVerifyModal(); return; } 615 if (!me.account) { await pickUsername(); } 616 try { await acquireCamera(); } 617 catch (e) { console.error("[18:cam] acquireCamera failed", e); say("camera err: " + e.message, "err"); return; } 618 const stream = sharedStream(); 619 console.log("[18:cam] stream", { 620 active: stream?.active, 621 tracks: stream?.getTracks().map(t => ({ kind: t.kind, enabled: t.enabled, muted: t.muted, readyState: t.readyState, settings: t.getSettings?.() })), 622 }); 623 let v = document.querySelector("video[data-18cam]"); 624 if (!v) { 625 v = document.createElement("video"); 626 v.srcObject = stream; 627 v.autoplay = true; 628 v.muted = true; 629 v.playsInline = true; 630 v.setAttribute("playsinline", ""); // iOS requires attr not just prop 631 v.setAttribute("webkit-playsinline", ""); // older iOS 632 // iOS Safari throttles/pauses display:none videos, so decoded frames 633 // stop flowing into drawImage. Keep the element on-page and merely 634 // invisible. 635 v.style.cssText = "position:absolute;
635left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;"; 636 v.dataset["18cam"] = "1"; 637 document.body.appendChild(v); 638 } 639 try { await v.play(); console.log("[18:cam] video.play ok"); } 640 catch (e) { console.warn("[18:cam] video.play rejected", e); } 641 // Clear the inner body only (keep the me-tile's profile button wrapper). 642 meTileBody.innerHTML = ""; 643 selfCanvas.hidden = false; 644 selfCanvas.className = "ascii-canvas self"; 645 meTileBody.appendChild(selfCanvas); 646 updateSelfMicVisual(); 647 updateCamLauncher(); 648 camsEl.classList.add("on"); 649 if (stopRender) stopRender(); 650 const selfFace = makeFaceCropper(v); 651 console.log("[18:cam] faceCropper", { hasDetector: selfFace.hasDetector }); 652 await new Promise((resolve) => { 653 if (v.readyState >= 2 && v.videoWidth) return resolve(); 654 const onReady = () => { v.removeEventListener("loadeddata", onReady); resolve(); }; 655 v.addEventListener("loadeddata", onReady); 656 setTimeout(resolve, 800); 657 }); 658 console.log("[18:cam] video ready", { 659 videoWidth: v.videoWidth, videoHeight: v.videoHeight, 660 readyState: v.readyState, paused: v.paused, currentTime: v.currentTime, 661 }); 662 // Render the symbolic face always. Chrome dropped FaceDetector so the 663 // real-tracking gate was locking everyone into pixel-ASCII. Without a 664 // tracker, the symbolic renderer falls back to sampling the center of 665 // the frame â crude but at least draws the face and picks up blinks 666 // when the user is roughly centered. Real cross-browser tracking 667 // requires MediaPipe (not wired). 668 console.log("[18:cam] renderer â symbolic (native FaceDetector present:", selfFace.hasDetector, ")"); 669 const selfLevel = makeLevelMeter(stream); 670 stopRender = startLiveRender(v, selfCanvas, { 671 fps: 12, 672 // mirror:true so the self glyph tracks like a mirror â you move 673 // right in the room, the glyph moves right on screen. 674 mirror: true, 675 getCrop: () => selfFace.cropRect(), 676 getAudioLevel: selfLevel, 677 render: renderSymbolicFace, 678 }); 679 if (!ws) connectSignaling(); 680 // Attach the newly-acquired tracks to every existing peer connection. 681 // Perfect-negotiation kicks in via negotiationneeded and renegotiates. 682 mesh?.attachLocalStream(sharedStream()); 683} 684function stopCamera() { 685 stopRender?.(); stopRender = null; 686 releaseCamera(); 687 document.querySelector("video[data-18cam]")?.remove(); 688 selfCanvas.hidden = true; 689 selfCanvas.remove(); 690 // Restore the me-tile's default rendering (username / avatar / sign-in). 691 renderMeTile({ verified: !!account, account }); 692 if (!peerTiles.size) camsEl.classList.remove("on"); 693 mesh?.detachLocalStream(); 694} 695// Event-delegated cam-start click. Fires only when the user is already 696// signed in â sign-in + pick-username paths wire their own handler in 697// renderMeTile (where we know which label to show). 698meTileBody.addEventListener("click", (e) => { 699 if (!e.target.closest("#camStart")) return; 700 if (account) startCamera(); 701}); 702 703// Click your own tile to toggle your mic on/off. (Used to stop the camera 704// entirely â moved the stop-cam gesture out of the click path so mic 705// toggling isn't destructive.) 706function toggleSelfMic() { 707 const s = sharedStream(); 708 const track = s?.getAudioTracks?.()[0]; 709 if (!track) { say("no mic track", "err"); return; } 710 track.enabled = !track.enabled; 711 updateSelfMicVisual(); 712} 713function updateSelfMicVisual() { 714 const s = sharedStream(); 715 const track = s?.getAudioTracks?.()[0]; 716 const off = track && !track.enabled; 717 selfCanvas.classList.toggle("muted", !!off); 718 const who = account?.username ? "@" + account.username : "you"; 719 selfCanvas.title = `${who} â ${off ? "mic muted (click to unmute)" : "click to mute mic"}`; 720} 721// Stop the click from bubbling to meTileEl (which would open the profile 722// panel). Mic toggle is the primary self-cam gesture; profile panel 723// opens via the me-tile's background (i.e. when cam is OFF). 724selfCanvas.addEventListener("click", (e) => { e.stopPropagation(); toggleSelfMic(); }); 725 726// âââââââââ peer tiles âââââââââ 727const peerTiles = new Map(); 728function labelFor(peerId) { 729 const name = peerNames.get(peerId); 730 return name ? "@" + name : peerId.slice(0, 6); 731} 732function labelTile(peerId) { 733 // If the peer just acquired a username, create their tile now â 734 // anonymous peers without a username don't get tiles at all. 735 const name = peerNames.get(peerId); 736 if (!peerTiles.has(peerId) && name) { 737 addTile(peerId); 738 return; 739 } 740 const t = peerTiles.get(peerId); 741 if (!t) return; 742 const lbl = labelFor(peerId); 743 t.canvas.title = lbl; 744 if (t.nameLabel) t.nameLabel.textContent = lbl; 745 t.username = peerNames.get(peerId) || null; 746} 747// Dedup + anonymous filter: 748// 1. Peers without a username (just browser-fingerprint IDs like 749// "ecaf30") don't get a tile â they're wasted sidebar space. 750// When/if they sign in, peer-joined with a name repaints via 751// labelTile. 752// 2. A peer sharing our own username (a second tab/device) is 753// skipped to avoid double-rendering self. 754// 3. A peer sharing another already-rendered peer's username is 755// skipped (same account multiple connections). 756function shouldSkipPeer(peerId) { 757 const name = peerNames.get(peerId); 758 if (!name) return true; 759 if (account?.username && name.toLowerCase() === account.username.toLowerCase()) return true; 760 for (const [otherId, tile] of peerTiles) { 761 if (otherId === peerId) continue; 762 if (tile.username && tile.username.toLowerCase() === name.toLowerCase()) return true; 763 } 764 return false;
765} 766function addTile(peerId) { 767 if (peerTiles.has(peerId)) return peerTiles.get(peerId); 768 if (shouldSkipPeer(peerId)) return null; 769 camsEl.classList.add("on"); 770 // Wrap the canvas so we can overlay a username label for peers whose 771 // cam is off. The label mirrors how bot tiles display their name. 772 const wrap = document.createElement("div"); 773 wrap.className = "peer-tile"; 774 const canvas = document.createElement("canvas"); 775 canvas.className = "ascii-canvas"; 776 canvas.title = labelFor(peerId) + " â click to mute"; 777 wrap.appendChild(canvas); 778 const nameLabel = document.createElement("span"); 779 nameLabel.className = "peer-name"; 780 nameLabel.textContent = labelFor(peerId); 781 wrap.appendChild(nameLabel); 782 camsTopEl.appendChild(wrap); 783 const video = document.createElement("video"); 784 video.autoplay = true; 785 video.playsInline = true; 786 video.muted = false; 787 video.setAttribute("playsinline", ""); 788 video.setAttribute("webkit-playsinline", ""); 789 video.style.cssText = "position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;"; 790 document.body.appendChild(video); 791 canvas.addEventListener("click", () => togglePeerMute(peerId)); 792 const t = { canvas, video, wrap, nameLabel, stop: null, hasVideo: false, username: peerNames.get(peerId) || null }; 793 peerTiles.set(peerId, t); 794 return t; 795} 796function togglePeerMute(peerId) { 797 const t = peerTiles.get(peerId); if (!t) return; 798 t.video.muted = !t.video.muted; 799 t.canvas.classList.toggle("muted", t.video.muted); 800 const name = labelFor(peerId); 801 t.canvas.title = name + (t.video.muted ? " â muted (click to unmute)" : " â click to mute"); 802} 803function dropTile(peerId) { 804 const t = peerTiles.get(peerId); if (!t) return; 805 t.stop?.(); 806 if (t.wrap) t.wrap.remove(); else t.canvas.remove(); 807 t.video.remove(); 808 peerTiles.delete(peerId); 809 if (!peerTiles.size && selfCanvas.hidden) { 810 camsEl.classList.remove("on"); 811 } 812} 813function wireStream(peerId, stream) { 814 const t = addTile(peerId); if (!t) return; 815 t.video.srcObject = stream; t.video.play().catch(() => {}); 816 const peerFace = makeFaceCropper(t.video); 817 t.stop?.(); 818 // Peers render as the symbolic ASCII face too â the single MediaPipe 819 // FaceLandmarker in face.js is a shared singleton, so each tile drives 820 // its own cropRect() against its own video and gets per-peer eye-blink 821 // state in cropInfo.eyeState. 822 const peerLevel = makeLevelMeter(stream); 823 t.stop = startLiveRender(t.video, t.canvas, { 824 fps: 10, mirror: false, 825 getCrop: () => peerFace.cropRect(), 826 getAudioLevel: peerLevel, 827 render: renderSymbolicFace, 828 }); 829 t.hasVideo = true; 830 // Hide the username overlay once a live face tile is rendering â the 831 // tile itself is the identifier at that point. Keep it available via 832 // CSS `.peer-tile:has(.ascii-canvas.active) .peer-name { display: none }` 833 // isn't portable enough yet, so flip it with a class instead. 834 t.wrap?.classList.add("has-video"); 835} 836 837// âââââââââ signaling âââââââââ 838// Mesh lifecycle: we always want a mesh once we've joined, even without a cam, 839// so peers with cam can deliver their video to us. Turning the cam on after the 840// fact tears down and recreates the mesh so our local tracks get attached to 841// every peer connection (simpler than mid-negotiation addTrack choreography). 842let joinedInfo = null; // last "joined" payload, kept for re-creation 843let peersKnown = new Set(); // peers currently present per the signaling state 844const peerNames = new Map(); // peerId â @username (best-effort label on tiles) 845 846function createMesh() { 847 if (mesh || !joinedInfo) return; 848 mesh = new Mesh({ 849 selfId: joinedInfo.peerId, ws, 850 iceServers: joinedInfo.iceServers || [], 851 getLocalStream: () => sharedStream(), 852 onPeer: addTile, onPeerGone: dropTile, onStream: wireStream, 853 log: (...a) => console.log("[mesh]", ...a), 854 }); 855 // Initiate to every known peer. If we have no local stream, the PC ends up 856 // receive-only (addPeer adds tracks only when a stream exists); incoming 857 // offers from peers who do have cams still land on our onSignal path. 858 for (const p of peersKnown) mesh.addPeer(p, true); 859} 860 861function connectSignaling() { 862 const proto = location.protocol === "https:" ? "wss:" : "ws:"; 863 ws = new WebSocket(`${proto}//${location.host}/signal`); 864 ws.onopen = () => { 865 resetReconnectBackoff(); 866 ws.send(JSON.stringify({ t: "join", room: ROOM, name: account?.username || null })); 867 }; 868 ws.onmessage = async (ev) => { 869 let m;
869 try { m = JSON.parse(ev.data); } catch { return; } 870 // Re-broadcast on document so listeners that need to survive 871 // WS reconnects (the DM panel, feed grid) attach to the 872 // document instead of a specific ws instance. 873 try { document.dispatchEvent(new CustomEvent("slop:ws-msg", { detail: m })); } catch {} 874 if (m.t === "joined") { 875 myPeerId = m.peerId; 876 joinedInfo = m; 877 // m.peers can be array-of-{peerId,username} (current server) or 878 // array-of-peerId strings (older server). Normalize. 879 const rawPeers = m.peers || []; 880 peersKnown = new Set(); 881 for (const p of rawPeers) { 882 if (typeof p === "string") { peersKnown.add(p); } 883 else if (p?.peerId) { 884 peersKnown.add(p.peerId); 885 if (p.username) peerNames.set(p.peerId, p.username); 886 } 887 } 888 createMesh(); 889 } else if (m.t === "peer-joined") { 890 peersKnown.add(m.peerId); 891 if (m.username) peerNames.set(m.peerId, m.username); 892 mesh?.addPeer(m.peerId, false); 893 labelTile(m.peerId); 894 } else if (m.t === "peer-left") { 895 peersKnown.delete(m.peerId); 896 peerNames.delete(m.peerId); 897 mesh?.removePeer(m.peerId); dropTile(m.peerId); 898 } else if (m.t === "signal") { 899 if (!mesh) createMesh(); // signal arriving before mesh existed 900 await mesh?.onSignal(m.from, m.data); 901 } else if (m.t === "chat-history") { 902 for (const h of (m.messages || [])) { 903 renderChat(h); 904 if (h.gen_id) seenGenIds.add(String(h.gen_id)); 905 } 906 refreshFeedRailDimming(); 907 // Boot-path reveal: once the initial history has rendered, 908 // scroll to bottom and drop the visibility:hidden class so 909 // the log shows up pre-positioned instead of appearing to 910 // scroll after load. 911 scrollToBottom(); 912 requestAnimationFrame(() => { 913 scrollToBottom(); 914 logEl.classList.remove("booting"); 915 }); 916 } 917 else if (m.t === "chat") { 918 renderChat(m); 919 if (m.gen_id) { seenGenIds.add(String(m.gen_id)); refreshFeedRailDimming(); } 920 } 921 else if (m.t === "chat-delete") { applyChatDelete(m.id); } 922 else if (m.t === "todo-toggle") { applyTodoToggle(m.id, m.checked, m.by); } 923 else if (m.t === "parascene-feed") { applyFeedDelta(m.added || []); } 924 }; 925 ws.onclose = () => { 926 ws = null; joinedInfo = null; peersKnown.clear(); 927 mesh?.destroy(); mesh = null; 928 scheduleReconnect(); 929 }; 930} 931 932// Exponential-backoff reconnect so the chat self-heals after a network blip, 933// tab-sleep on mobile, or a transient server restart â no need for the user 934// to reload to see new messages. Capped at 15s. 935let reconnectTimer = null, reconnectDelay = 800; 936function scheduleReconnect() { 937 if (reconnectTimer) return; 938 reconnectTimer = setTimeout(() => { 939 reconnectTimer = null; 940 reconnectDelay = Math.min(reconnectDelay * 1.8, 15000); 941 connectSignaling(); 942 }, reconnectDelay); 943} 944// Reset the backoff on a successful open so the NEXT drop starts quick again. 945function resetReconnectBackoff() { reconnectDelay = 800; } 946// Recover faster when the user brings the tab back into focus â many mobile
947// browsers silently close the WS while backgrounded. 948window.addEventListener("visibilitychange", () => { 949 if (document.visibilityState === "visible" && !ws) { 950 reconnectDelay = 800; 951 if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } 952 connectSignaling(); 953 } 954}); 955function reconnectWs() { 956 if (ws) { try { ws.close(); } catch {} ws = null; } 957 mesh?.destroy(); mesh = null; 958 joinedInfo = null; peersKnown.clear(); 959 connectSignaling(); 960} 961 962// Dedupe by message id so chat-history replays on reconnect don't re-render 963// messages we've already shown. Falls back to a content-hash key for messages 964// without an id (legacy/bot injects). 965const renderedIds = new Set(); 966function renderKey(m) { 967 return m.id ? "id:" + m.id : "h:" + (m.from || "?") + ":" + (m.ts || 0) + ":" + (m.text || "").slice(0, 40); 968} 969// Messages from the same author within this window collapse into a visual 970// block â username only printed the first time, subsequent messages look like 971// continuations. 1 hour is the same heuristic iMessage/Slack use. 972const GROUP_WINDOW_MS = 60 * 60 * 1000; 973 974// scrollToBottom respects the 30vh bottom padding on .log â setting scrollTop 975// to scrollHeight just clamps to the max, leaving the last message naturally 976// pushed up by the padding. 977// The chat log never scrolls itself after boot. On page load we pre-position 978// at the bottom (freshest content in view); from there the user is in 979// control. No sticky-bottom, no snap-back on new messages â matching how 980// a terminal scrollback feels. 981function scrollToBottom() { 982 // Default scroll position = boundary at the bottom of the viewport, 983 // so the latest chat message is the bottom of what's visible and 984 // the grid sits just out of view below. (Without this guard, 985 // scrollHeight includes grid + 30vh padding and we'd land deep 986 // inside the grid.) 987 const boundary = logEl.querySelector(".chat-feed-boundary"); 988 if (boundary) { 989 logEl.scrollTop = Math.max(0, boundary.offsetTop - logEl.clientHeight + boundary.offsetHeight); 990 } else if (feedGridEl && feedGridEl.parentNode === logEl) { 991 logEl.scrollTop = Math.max(0, feedGridEl.offsetTop - logEl.clientHeight); 992 } else { 993 logEl.scrollTop = logEl.scrollHeight; 994 } 995} 996 997// âââââââââ sidebar image stack âââââââââ 998// Images that used to render inline in chat now live in a dedicated 999// right-side column under the webcams. The stack is bottom-anchored and 1000// keyed to the most-recent /gen message currently visible in the chat log. 1001// As the user scrolls through old conversations, different spans of gens 1002// come into view and the sidebar re-anchors. 1003 1004// Images now float right inside the chat flow at 20vw. The old sidebar 1005// image stack + IntersectionObserver anchor logic has been removed â the 1006// image just sits at the message that introduced it, and subsequent 1007// messages' text wraps around it naturally via CSS floats. 1008 1009// âââââââââ bot avatar tiles âââââââââ 1010// Alive bots appear alongside human webcams in cams-top. Uses the bot's 1011// source /gen image as the portrait for now; a proper generated avatar 1012// comes later. 1013async function refreshBotRoster() { 1014 try { 1015 const r = await fetch("/api/bots"); 1016 const j = await r.json(); 1017 const alive = j.alive || []; 1018 const existing = new Map(); 1019 for (const el of camsTopEl.querySelectorAll(".bot-avatar")) { 1020 existing.set(el.dataset.slug, el); 1021 } 1022 const seen = new Set(); 1023 for (const b of alive) { 1024 seen.add(b.slug); 1025 let tile = existing.get(b.slug); 1026 if (!tile) { 1027 tile = document.createElement("div"); 1028 tile.className = "bot-avatar"; 1029 tile.dataset.slug = b.slug; 1030 tile.innerHTML = `<span class="ba-name"></span>`; 1031 tile.addEventListener("click", () => { 1032 // Click a bot tile â open the full myspace-style 1033 // profile panel (bio, wall, guestbook, custom HTML, 1034 // inline DM composer). Bot avatar is the anch
1034or. 1035 openProfilePanel(tile.dataset.slug); 1036 }); 1037 camsTopEl.appendChild(tile); 1038 } 1039 tile.querySelector(".ba-name").textContent = b.name; 1040 // Prefer the dedicated profile picture; fall back to the 1041 // source /gen image if none yet. Empty when even the source 1042 // is missing â the username centered in the tile reads as a 1043 // placeholder on its own. 1044 const portrait = b.avatarImageUrl || b.imageUrl || null; 1045 if (portrait) { 1046 tile.style.backgroundImage = `url(${portrait})`; 1047 tile.dataset.img = portrait; 1048 tile.dataset.genId = String(b.avatarGenId || b.genId || ""); 1049 tile.classList.remove("no-pic"); 1050 } else { 1051 tile.style.backgroundImage = ""; 1052 tile.dataset.img = ""; 1053 tile.classList.add("no-pic"); 1054 } 1055 tile.title = b.backstory || b.name; 1056 } 1057 // Drop tiles for bots that died / went hidden. 1058 for (const [slug, el] of existing) { 1059 if (!seen.has(slug)) el.remove(); 1060 } 1061 if (alive.length) camsEl.classList.add("on"); 1062 } catch {} 1063} 1064setInterval(refreshBotRoster, 15_000); // cheap enough; bot list changes rarely 1065 1066// Messages starting with ".. " CONCATENATE onto the previous chat line 1067// from the same author (if that line was posted within 60s). Enables 1068// bots to stream radio-chatter beats that read as one continuous voice 1069// rather than a tower of mini-bubbles. Also usable by humans. 1070const CONTINUE_PREFIX = ".. "; 1071const CONTINUE_WINDOW_MS = 60 * 1000; 1072 1073function renderChat(m) { 1074 const key = renderKey(m); 1075 if (renderedIds.has(key)) return; 1076 renderedIds.add(key); 1077 1078 // Replace-the-ack path: when a substantive bot message arrives, 1079 // remove any pending ack row from the SAME bot. We walk backward 1080 // past non-message siblings (the feed-grid, sentinel, or other 1081 // chrome attached to logEl) and drop any ack within the last 1082 // 60s from this from. Multiple acks (rare) are all cleared. 1083 if (!m.is_ack && m.is_bot && !((m.text || "").startsWith(CONTINUE_PREFIX))) { 1084 const fromKey = m.from || ""; 1085 // Up to 8 .msg rows back, drop ack rows from this bot. 1086 let row = logEl.lastElementChild; 1087 let scanned = 0; 1088 while (row && scanned < 12) { 1089 scanned++; 1090 if (row.classList?.contains("msg")) { 1091 if (row.dataset.isAck === "1" 1092 && row.dataset.from === fromKey 1093 && Math.abs(Date.now() - Number(row.dataset.ts)) < 60_000) { 1094 const next = row.previousElementSibling; 1095 row.remove(); 1096 row = next; 1097 continue; 1098 } 1099 // Stop on first non-ack .msg from same bot â the ack 1100 // was already replaced earlier; nothing to do. 1101 if (row.dataset.from === fromKey) break; 1102 } 1103 row = row.previousElementSibling; 1104 } 1105 } 1106 1107 // Continuation path: if this message is from the same author as the 1108 // bottom-most .msg in the log AND starts with ".. " AND the prev is 1109 // fresh enough, append to the prev's body instead of adding a new 1110 // row. We still register the id in renderedIds so the broadcast 1111 // isn't re-processed if replayed via chat-history. 1112 if ((m.text || "").startsWith(CONTINUE_PREFIX)) { 1113 const prevRow = logEl.lastElementChild; 1114 if (prevRow?.classList.contains("msg") 1115 && prevRow.dataset.from === (m.from || "") 1116 && Math.abs(Date.now() - Number(prevRow.dataset.ts)) < CONTINUE_WINDOW_MS) { 1117 const body = prevRow.querySelector(".body"); 1118 if (body) { 1119 const tail = (m.text || "").slice(CONTINUE_PREFIX.length).trim(); 1120 const tailEl = document.createElement("span"); 1121 tailEl.className = "msg-cont"; 1122 // If the previous row was an instant ack ("on it." / 1123 // "hmph."), render the continuation as a plain space â 1124 // the leader already ends with punctuation. For organic
1125 // radio-chatter beats from the bot itself, keep the 1126 // visible ".." dot so the cadence is clear. 1127 if (prevRow.dataset.isAck === "1") { 1128 tailEl.innerHTML = " " + linkify(esc(tail)); 1129 } else { 1130 tailEl.innerHTML = ` <span class="msg-cont-dot">..</span> ${linkify(esc(tail))}`; 1131 } 1132 body.appendChild(tailEl); 1133 // Update the ts so further ".. " messages can chain. 1134 prevRow.dataset.ts = String(m.ts || Date.now()); 1135 // Clear the ack flag â once continued, this is no 1136 // longer just an ack but a substantive line. 1137 prevRow.dataset.isAck = ""; 1138 return; 1139 } 1140 } 1141 // No prev to continue onto â fall through and render as a 1142 // regular line (stripping the ".. " so it doesn't look weird). 1143 m = { ...m, text: (m.text || "").slice(CONTINUE_PREFIX.length).trim() }; 1144 } 1145 1146 const el = document.createElement("div"); el.className = "msg"; 1147 el.dataset.from = m.from || ""; 1148 el.dataset.ts = String(m.ts || Date.now()); 1149 if (m.id) el.dataset.msgId = m.id; 1150 if (m.is_ack) el.dataset.isAck = "1"; 1151 // Group with the immediately-preceding message if it's a .msg from the same 1152 // author within the window. Sys lines break the grouping (they're a 1153 // different DOM class) which is the desired behavior. 1154 const prev = logEl.lastElementChild; 1155 if (prev?.classList.contains("msg") 1156 && prev.dataset.from === el.dataset.from 1157 && Math.abs(Number(el.dataset.ts) - Number(prev.dataset.ts)) < GROUP_WINDOW_MS) { 1158 el.classList.add("grouped"); 1159 } 1160 const mine = !!(m.from && account?.username && m.from === account.username); 1161 if (m.gen_id) el.dataset.genId = String(m.gen_id); 1162 if (m.priv) el.classList.add("priv"); 1163 1164 // Images float right at the message that posted them. Text from THIS 1165 // row + every subsequent row flows around until the float clears at 1166 // the end of the image's natural height. We put the float OUTSIDE the 1167 // .body span so block-level wrapping works across messages. 1168 // width/height attributes give the browser intrinsic-ratio hints 1169 // before the bytes arrive â combined with .msg-attach's fixed 1170 // aspect-ratio CSS, the box doesn't reflow when the image lands. 1171 // 1024 reflects the typical parascene gen (square 1024Ã1024); the 1172 // attribute encodes ratio not pixel size, so non-1024 outputs still 1173 // render correctly under the responsive width. 1174 const attachHtml = m.attach_video 1175 ? `<a class="msg-attach msg-attach-video" data-gen-id="${esc(String(m.gen_id || ""))}">` + 1176 `<video src="${esc(m.attach_video)}" width="1024" height="1024" data-autoloop="1" muted loop playsinline preload="metadata"></video>` + 1177 (m.gen_id ? `<button type="button" class="img-id-badge" data-gen-id="${esc(String(m.gen_id))}" title="copy id">${esc(String(m.gen_id))}</button>` : "") + 1178 `</a>` 1179 : m.attach 1180 ? `<a class="msg-attach" href="${esc(m.attach)}" data-gen-id="${esc(String(m.gen_id || ""))}">` + 1181 `<img width="1024" height="1024" loading="lazy" src="${esc(m.attach)}" alt="">` + 1182 (m.gen_id ? `<button type="button" class="img-id-badge" data-gen-id="${esc(String(m.gen_id))}" title="copy id">${esc(String(m.gen_id))}</button>` : "") + 1183 `</a>` 1184 : ""; 1185 1186 // Render synchronously first so the row appears instantly, then 1187 // resolve the async body (which does subtle.digest for todo ids) and 1188 // patch it in. Keeps the append-order stable while letting checkboxes 1189 // be actual DOM inputs. 1190 const audio = m.audio_url ? audioPlayerHtml(m.audio_url) : ""; 1191 // Messages originating from an interactive wrap CLI session get a 1192 // hammer-and-pick symbol (U+2692 + VS15 for text presentation) after 1193 // the name, so it's visually obvious the bot is speaking from deep 1194 // inside a claude-code session rather than the lightweight 1195 // Messages-API tool-use path.
1196 const wrapBadge = m.via_wrap ? `<span class="via-wrap" title="speaking from an interactive claude-code session">\u2692\uFE0E</span>` : ""; 1197 // Audio-first mode: when the message has a waveform, treat the text 1198 // (or the STT transcript for user voice notes) as a caption hidden 1199 // behind a hover/tap reveal, not a primary body line. The waveform 1200 // itself gets the main real estate. Both user and bot audio msgs 1201 // follow the same rule so the room doesn't show the spoken text 1202 // twice (once as waveform, once as typed body). 1203 const captionText = (m.audio_url && (m.text || m.transcript)) ? (m.text || m.transcript) : ""; 1204 const audioFirst = !!m.audio_url; 1205 if (audioFirst) el.classList.add("audio-first"); 1206 // Body holds either regular text, or (in audio-first mode) the hidden 1207 // caption which the .audio-first CSS rules display:none by default. 1208 let bodyText = audioFirst ? captionText : (m.text || ""); 1209 // Aside detection: body shaped exactly like "({...})" is a wrap-CLI 1210 // muttered line. Strip the wrapping characters and tag .aside so it 1211 // renders dimmed/italic. is_ack messages are NOT asides â they're 1212 // the leader of a continuation pair ("on it." + ".. <real reply>") 1213 // and should render as regular chat text. 1214 if (!m.is_ack) { 1215 const m2 = bodyText.trim().match(/^\(\{([\s\S]+)\}\)$/); 1216 if (m2) { 1217 bodyText = m2[1].trim(); 1218 el.classList.add("aside"); 1219 } 1220 } 1221 // Admin: floating à on hover, upper-right of the username, absolutely 1222 // positioned so it doesn't reflow the row. Only rendered for the 1223 // admin user's own session (server still enforces on DELETE). 1224 const canDelete = !!account?.isAdmin && m.id; 1225 const delBadge = canDelete ? `<button class="msg-del" type="button" title="delete" data-id="${esc(m.id)}">Ã</button>` : ""; 1226 if (canDelete) el.classList.add("can-del"); 1227 el.innerHTML = attachHtml + 1228 `<span class="who">${esc(m.from)}${wrapBadge}${delBadge}</span> <span class="body">${audio}${audioFirst && captionText ? '<span class="audio-caption">' + linkify(esc(bodyText)) + '</span>' : linkify(esc(bodyText))}</span>`; 1229 // If the chat/feed boundary is mounted, insert this row BEFORE it 1230 // so messages stay above the boundary + grid in DOM order. 1231 const boundary = logEl.querySelector(".chat-feed-boundary"); 1232 if (boundary) { 1233 logEl.insertBefore(el, boundary); 1234 } else if (feedGridEl && feedGridEl.parentNode === logEl) { 1235 logEl.insertBefore(el, feedGridEl); 1236 } else { 1237 logEl.appendChild(el); 1238 } 1239 // Async replace for todo/newline rendering. In audio-first mode we 1240 // keep the waveform first and the caption hidden as a CSS-controlled 1241 // reveal; in normal mode the audio (if any) trails the body text. 1242 const msgIdForTodo = m.id || `${m.from}-${m.ts}`; 1243 renderMessageBody(msgIdForTodo, bodyText).then(html => { 1244 const body = el.querySelector(".body"); 1245 if (!body) return; 1246 if (audioFirst) { 1247 body.innerHTML = audio + (captionText ? `<span class="audio-caption">${html}</span>` : ""); 1248 } else { 1249 body.innerHTML = html + audio; 1250 } 1251 if (m.audio_url) {
1252 const player = el.querySelector(".audio-msg"); 1253 wireAudioPlayer(player); 1254 player?.__drawWave?.(); 1255 // Auto-play contiguous: if the user just recorded audio, any 1256 // bot audio arriving within the window plays automatically. 1257 const recentMicSend = (Date.now() - lastUserMicTs) < AUTO_PLAY_WINDOW_MS; 1258 if (recentMicSend && m.is_bot && !document.hidden) { 1259 try { 1260 const btn = player?.querySelector(".audio-play"); 1261 btn?.click(); 1262 } catch {} 1263 } 1264 } 1265 // Suno share-links â resolve to direct mp3 + render inline player 1266 // with cover art and waveform. We attach AFTER the message body so 1267 // the original link stays clickable (small text), and the player 1268 // appears as a card under it. 1269 expandSunoLinks(el, bodyText); 1270 }); 1271 1272 // Click attachment opens the lightbox (parascene integration in there). 1273 // Click on the floating id-badge copies the id and stops the lightbox. 1274 const attachEl = el.querySelector(".msg-attach"); 1275 if (attachEl) { 1276 attachEl.addEventListener("click", (e) => { 1277 const badge = e.target.closest(".img-id-badge"); 1278 if (badge) { 1279 e.preventDefault(); e.stopPropagation(); 1280 copyIdBadge(badge); 1281 return; 1282 } 1283 e.preventDefault(); 1284 openLightbox(attachEl.href, attachEl.dataset.genId || null); 1285 }); 1286 } 1287} 1288 1289// Visual feedback: copy id, briefly flip the badge to "copied". 1290async function copyIdBadge(badge) { 1291 const id = badge?.dataset.genId; 1292 if (!id) return; 1293 try { 1294 await navigator.clipboard.writeText(id); 1295 const orig = badge.textContent; 1296 badge.textContent = "copied"; 1297 badge.classList.add("img-id-badge-copied"); 1298 setTimeout(() => { badge.textContent = orig; badge.classList.remove("img-id-badge-copied"); }, 800); 1299 } catch { 1300 // Fallback: select-and-execCommand (older Safari). 1301 try { 1302 const r = document.createRange(); 1303 const span = document.createElement("span"); 1304 span.textContent = id; 1305 document.body.appendChild(span); 1306 r.selectNode(span); 1307 getSelection().removeAllRanges(); 1308 getSelection().addRange(r); 1309 document.execCommand("copy"); 1310 getSelection().removeAllRanges(); 1311 span.remove(); 1312 badge.textContent = "copied"; 1313 setTimeout(() => { badge.textContent = id; }, 800); 1314 } catch {} 1315 } 1316} 1317 1318// Admin: delegated click for the floating à delete glyph. One listener 1319// on the log handles every row without per-render wiring. The server 1320// re-checks admin; the à is just a UI hint. 1321// plain click â delete that one message 1322// shift+click â delete that message AND every message after it in 1323// the room (admin/dadchords only). Title flips to 1324// "delete all after" while shift is held. 1325logEl.addEventListener("click", async (e) => { 1326 const btn = e.target.closest(".msg-del"); 1327 if (btn) { 1328 e.preventDefault(); 1329 e.stopPropagation(); 1330 const id = btn.dataset.id; 1331 if (!id) return; 1332 const cascade = !!e.shiftKey; 1333 if (cascade && !confirm("Delete this message AND all messages after it?")) return; 1334 btn.disabled = true; 1335 try { 1336 const url = cascade 1337 ? `/api/chat/${encodeURIComponent(id)}?after=1` 1338 : `/api/chat/${encodeURIComponent(id)}`; 1339 const r = await fetch(url, { method: "DELETE" }); 1340 if (!r.ok) { btn.disabled = false; return; } 1341 const j = await r.json().catch(() => ({})); 1342 if (cascade && Array.isArray(j.deleted)) { 1343 for (const did of j.deleted) applyChatDelete(did); 1344 } else { 1345 applyChatDelete(id); 1346 } 1347 } catch { 1348 btn.disabled = false; 1349 } 1350 return; 1351 } 1352 // Click on usernames in the chat log no longer opens a profile â 1353 // the deep-link routes (slopstation.ai/<user>) and the right-rail 1354 // tile click are still available. Profile open via the chat log 1355 // was too easy to misclick on a small mobile target. 1356}); 1357 1358// Shift-key live label: while shift is held, the à tooltip flips to 1359// "delete all after". Avoids surprise â the user sees the cascade 1360// hint before clicking. 1361function setDelTitles(cascade) { 1362 const text = cascade ? "delete all after" : "delete"; 1363 for (const b of logEl.querySelectorAll(".msg-del")) b.title = text; 1364} 1365window.addEventListener("keydown", (e) => { if (e.key === "Shift") setDelTitles(true); }); 1366window.addEventListener("keyup", (e) => { if (e.key === "Shift") setDelTitles(false); }); 1367window.addEventListener("blur", () => setDelTitles(false)); 1368 1369function applyChatDelete(id) { 1370 if (!id) return;
1371 const row = logEl.querySelector(`.msg[data-msg-id="${CSS.escape(id)}"]`); 1372 if (row) row.remove(); 1373 renderedIds.delete("id:" + id); 1374} 1375// âââââââââ inline voice-message player âââââââââ 1376// Minimal inline component: tiny play/pause button, ~48 thin bars for a 1377// waveform, same height as text so chat lines stay dense. The waveform is 1378// drawn from the decoded audio buffer the first time the user plays (we 1379// don't eagerly fetch 48 bars à N messages â that'd hammer the CDN on 1380// load). Before playing, a flat placeholder strip is shown. 1381 1382// âââââââââ universal audio transport âââââââââ 1383// 1384// Single global "current track". Every wireAudioPlayer registers its 1385// HTMLAudioElement here on first ensureAudio. Playing one stops any 1386// other. On 'ended', auto-advance to the next registered track in 1387// document order so a queue of voice messages / Suno songs flows 1388// continuously. The transport row above the send button drives this 1389// from outside the chat (prev / play-pause / next). 1390const audioTransport = { 1391 tracks: [], // [{ audio, el }] â el is the .audio-msg DOM 1392 current: null, // currently active track (audio + el) 1393 register(audio, el) { 1394 // De-dupe by Audio object. 1395 if (this.tracks.some(t => t.audio === audio)) return; 1396 this.tracks.push({ audio, el }); 1397 audio.addEventListener("play", () => this._onPlay(audio)); 1398 audio.addEventListener("pause", () => this._onPauseOrEnd()); 1399 audio.addEventListener("ended", () => this._onEnded(audio)); 1400 this._show(); 1401 }, 1402 _onPlay(audio) { 1403 // Pause everything else. 1404 for (const t of this.tracks) { 1405 if (t.audio !== audio && !t.audio.paused) try { t.audio.pause(); } catch {} 1406 } 1407 this.current = this.tracks.find(t => t.audio === audio) || null; 1408 this._refresh(); 1409 }, 1410 _onPauseOrEnd() { this._refresh(); }, 1411 _onEnded(audio) { 1412 const idx = this.tracks.findIndex(t => t.audio === audio); 1413 if (idx < 0) return; 1414 // Reset position so a future play starts from the top, not stuck at end. 1415 try { audio.currentTime = 0; } catch {} 1416 const next = this.tracks[idx + 1]; 1417 if (next) { 1418 try { next.audio.play().catch(() => {}); } catch {} 1419 } 1420 this._refresh(); 1421 }, 1422 _show() { 1423 const wrap = document.getElementById("transport"); 1424 if (wrap) wrap.hidden = false; 1425 }, 1426 _refresh() { 1427 const playBtn = document.getElementById("trPlay"); 1428 if (!playBtn) return; 1429 const playing = this.current && !this.current.audio.paused; 1430 playBtn.textContent = playing ? "ââ" : "â·"; 1431 playBtn.classList.toggle("tr-active", !!playing); 1432 }, 1433 play() { 1434 // If something's already current, toggle. Otherwise start the first. 1435 const target = this.current || this.tracks[0]; 1436 if (!target) return; 1437 if (target.audio.paused) { 1438 this.current = target; 1439 try { target.audio.play().catch(() => {}); } catch {} 1440 } else { 1441 try { target.audio.pause(); } catch {} 1442 } 1443 }, 1444 next() { 1445 if (!this.tracks.length) return; 1446 const idx = this.current ? this.tracks.findIndex(t => t === this.current) : -1; 1447 const target = this.tracks[Math.min(this.tracks.length - 1, idx + 1)]; 1448 if (!target) return; 1449 this._stopAllExcept(target.audio); 1450 this.current = target; 1451 try { target.audio.play().catch(() => {}); } catch {} 1452 }, 1453 prev() { 1454 if (!this.tracks.length) return; 1455 const idx = this.current ? this.tracks.findIndex(t => t === this.current) : 0; 1456 const target = this.tracks[Math.max(0, idx - 1)]; 1457 if (!target) return; 1458 this._stopAllExcept(target.audio); 1459 this.current = target; 1460 try { target.audio.play().catch(() => {}); } catch {} 1461 }, 1462 _stopAllExcept(keep) { 1463 for (const t of this.tracks) { 1464 if (t.audio !== keep && !t.audio.paused) try { t.audio.pause(); t.audio.currentTime = 0; } catch {} 1465 } 1466 }, 1467}; 1468// Wire transport buttons. 1469document.getElementById("trPlay")?.addEventListener("click", () => audioTransport.play()); 1470document.getElementById("trNext")?.addEventListener("click", () => audioTransport.next()); 1471document.getElementById("trPrev")?.addEventListener("click", () => audioTransport.prev()); 1472 1473// Detect any suno.com share/song URLs in this message's text, resolve 1474// them server-side to direct mp3 + cover, and append an inline player 1475// card to the message row. One card per link, capped at 3 to avoid 1476// abuse. Cached on the resolver side;
1476 re-render is a no-op for the 1477// same msg row (we tag with .suno-card data-share so we don't double-add). 1478async function expandSunoLinks(rowEl, text) { 1479 if (!text) return; 1480 const matches = String(text).match(/https?:\/\/suno\.com\/(?:s|song)\/[A-Za-z0-9_-]+/g); 1481 if (!matches?.length) return; 1482 // Hide the original suno link in the message body now that the 1483 // card replaces it. We strip the rendered <a> for each matched 1484 // URL â keep any prefix/suffix text the user typed around it. 1485 const body = rowEl.querySelector(".body"); 1486 if (body) { 1487 for (const a of body.querySelectorAll("a.url-btn")) { 1488 if (matches.includes(a.href)) a.remove(); 1489 } 1490 } 1491 const seen = new Set(); 1492 const urls = matches.filter(u => { 1493 if (seen.has(u)) return false; 1494 seen.add(u); 1495 return true; 1496 }).slice(0, 3); 1497 for (const url of urls) { 1498 // Don't re-add if a card for this link already rendered. 1499 if (rowEl.querySelector(`.suno-card[data-url="${CSS.escape(url)}"]`)) continue; 1500 // Placeholder so the layout reserves space while resolving. 1501 const card = document.createElement("div"); 1502 card.className = "suno-card suno-loading"; 1503 card.dataset.url = url; 1504 card.innerHTML = `<div class="suno-card-inner"><div class="suno-thumb"></div><div class="suno-meta"><div class="suno-title">loadingâ¦</div></div></div>`; 1505 rowEl.appendChild(card); 1506 try { 1507 const r = await fetch("/api/suno-resolve?url=" + encodeURIComponent(url)); 1508 if (!r.ok) { card.classList.remove("suno-loading"); card.classList.add("suno-error"); card.querySelector(".suno-title").textContent = "couldn't resolve suno link"; continue; } 1509 const j = await r.json(); 1510 const title = j.title || "suno track"; 1511 const cover = j.image_url || ""; 1512 const audio = j.audio_url; 1513 card.classList.remove("suno-loading"); 1514 // Both kinds use the same compact 64Ã64-thumb card layout. 1515 // Video shares get a silent autoplay-loop <video> in the 1516 // thumb slot (no controls, plays as an animated thumbnail). 1517 // Audio is still wired through the waveform play button so 1518 // the user can opt in to hearing the song. 1519 const thumbHtml = (j.kind === "video" && j.video_url) 1520 ? `<video class="suno-thumb suno-thumb-video" data-autoloop="1" muted loop playsinline 1521 ${cover ? `poster="${esc(cover)}"` : ""} 1522 src="${esc(j.video_url)}"></video>` 1523 : (cover 1524 ? `<a class="suno-thumb" href="${esc(url)}" target="_blank" rel="noopener noreferrer" style="background-image:url(${esc(cover)})"></a>` 1525 : `<div class="suno-thumb"></div>`); 1526 card.innerHTML = ` 1527 <div class="suno-card-inner"> 1528 ${thumbHtml} 1529 <div class="suno-meta"> 1530 <div class="suno-title">${esc(title)}</div> 1531 ${audio ? audioPlayerHtml(audio) : ""} 1532 </div> 1533 </div>`;
1534 const player = card.querySelector(".audio-msg"); 1535 if (player) wireAudioPlayer(player); 1536 } catch (e) { 1537 card.classList.remove("suno-loading"); 1538 card.classList.add("suno-error"); 1539 card.querySelector(".suno-title").textContent = "suno fetch failed"; 1540 } 1541 } 1542} 1543 1544function audioPlayerHtml(url) { 1545 const u = esc(url); 1546 return `<span class="audio-msg" data-src="${u}" aria-label="voice message">` + 1547 `<button class="audio-play" type="button" aria-label="play"><span class="audio-play-glyph">â¶</span></button>` + 1548 `<canvas class="audio-wave" width="192" height="14"></canvas>` + 1549 `</span>`; 1550} 1551 1552function wireAudioPlayer(el) { 1553 if (!el) return; 1554 const src = el.dataset.src; 1555 const btn = el.querySelector(".audio-play"); 1556 const glyph = el.querySelector(".audio-play-glyph"); 1557 const canvas = el.querySelector(".audio-wave"); 1558 let audio = null; 1559 let drawn = false; 1560 1561 async function ensureAudio() { 1562 if (audio) return audio; 1563 audio = new Audio(src); 1564 audio.preload = "metadata"; 1565 audio.addEventListener("ended", () => { glyph.textContent = "â¶"; }); 1566 audio.addEventListener("pause", () => { glyph.textContent = "â¶"; }); 1567 audio.addEventListener("play", () => { glyph.textContent = "â®â®"; }); 1568 // Register with the universal transport so prev/play/next can 1569 // drive this audio, and so playing it stops every other audio 1570 // on the page (one track at a time). 1571 audioTransport.register(audio, el); 1572 return audio; 1573 } 1574 1575 async function drawWave() { 1576 if (drawn) return; 1577 drawn = true; 1578 try { 1579 // AudioContext creation requires a user gesture on iOS/Safari, 1580 // so lazy-create on first interaction or when a context 1581 // already exists from the mic flow. 1582 const Ctx = window.AudioContext || window.webkitAudioContext; 1583 if (!audioCtx && Ctx) { 1584 try { audioCtx = new Ctx(); } catch { /* may throw pre-gesture */ } 1585 } 1586 if (!audioCtx) { drawn = false; return; } // retry later on click 1587 const resp = await fetch(src); 1588 if (!resp.ok) { 1589 // Audio file is gone (most often a stale chat-log entry 1590 // referring to a long-ago-deleted opus). Hide the 1591 // player rather than leaving a broken control. Silent 1592 // â don't spam the console for known-missing files. 1593 el.classList.add("audio-msg-missing"); 1594 el.setAttribute("title", "audio unavailable"); 1595 return; 1596 } 1597 const buf = await resp.arrayBuffer(); 1598 const decoded = await audioCtx.decodeAudioData(buf.slice(0)); 1599 const data = decoded.getChannelData(0); 1600 const bars = Math.floor(canvas.width / 3); // 3px per bar incl. gap 1601 const step = Math.max(1, Math.floor(data.length / bars)); 1602 const ctx2 = canvas.getContext("2d"); 1603 ctx2.clearRect(0, 0, canvas.width, canvas.height); 1604 ctx2.fillStyle = "rgba(228, 228, 228, 0.85)"; 1605 for (let i = 0; i < bars; i++) { 1606 let peak = 0; 1607 const start = i * step; 1608 const end = Math.min(start + step, data.length); 1609 for (let j = start; j < end; j++) { 1610 const v = Math.abs(data[j]); 1611 if (v > peak) peak = v; 1612 } 1613 const h = Math.max(1, peak * canvas.height); 1614 const x = i * 3; 1615 const y = (canvas.height - h) / 2; 1616 ctx2.fillRect(x, y, 2, h); 1617 } 1618 } catch (e) { 1619 drawn = false; 1620 console.warn("[audio-wave]", e.message); 1621 } 1622 } 1623 // Expose so renderChat can call it right after appending the element. 1624 el.__drawWave = drawWave; 1625 1626 // Waveform tap toggles the caption â hover works on desktop via CSS 1627 // alone; mobile needs this click handler so users without hover can 1628 // reveal the text behind a voice note. 1629 canvas.addEventListener("click", (ev) => { 1630 ev.stopPropagation(); 1631 el.closest(".msg")?.classList.toggle("caption-open"); 1632 }); 1633 1634 btn.addEventListener("click", async (ev) => { 1635 ev.stopPropagation(); 1636 if (el.classList.contains("audio-msg-missing")) return; 1637 const a = await ensureAudio(); 1638 if (a.paused) { 1639 drawWave(); 1640 try { await a.play(); } catch (e) { 1641 // 404 / decode error â mark missing instead of spamming. 1642 if (a.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED || 1643 a.error?.code === MediaError.MEDIA_ERR_NETWORK) { 1644 el.classList.add("audio-msg-missing"); 1645 el.setAttribute("title", "audio unavailable"); 1646 } else { 1647 console.warn("[audio-play]", e.message); 1648 } 1649 } 1650 } else { 1651 a.pause(); 1652 } 1653 }); 1654} 1655 1656// âââââââââ self profile panel âââââââââ 1657// Click own me-tile â full-bleed panel with current avatar, history stack 1658// of past avatars, and a grid of the user's liked parascene creations. 1659// Picking any image becomes the new avatar; the prior one is pushed to 1660// history for one-click revert. Placeholder for future user settings. 1661const profilePanelEl = document.getElementById("profilePanel"); 1662document.getElementById("ppClose").addEventListener("click", () => { 1663 profilePanelEl.hidden = true; 1664}); 1665document.getElementById("ppClearBtn").addEventListener("click", async () => { 1666 await fetch("/api/account/avatar", { method: "DELETE" }); 1667 await loadAccount(); 1668 renderProfilePanel(); 1669}); 1670// Escape closes. 1671document.addEventListener("keydown", (e) => { 1672 if (e.key === "Escape" && !profilePanelEl.hidden) profilePanelEl.hidden = true; 1673}); 1674 1675async function openAvatarPicker() { 1676 profilePanelEl.hidden = false;
1677 await renderProfilePanel(); 1678} 1679 1680async function renderProfilePanel() { 1681 if (!account) { profilePanelEl.hidden = true; return; } 1682 const cur = document.getElementById("ppCurrent"); 1683 // Remove any prior video sibling we injected. 1684 cur.parentNode?.querySelector("video.pp-current-video")?.remove(); 1685 if (account.avatarUrl && isVideoAvatar(account.avatarUrl)) { 1686 cur.style.display = "none"; 1687 const v = document.createElement("video"); 1688 v.className = "pp-current pp-current-video"; 1689 v.src = account.avatarUrl; 1690 v.muted = true; v.loop = true; v.playsInline = true; 1691 v.preload = "metadata"; 1692 v.dataset.autoloop = "1"; 1693 cur.parentNode?.insertBefore(v, cur.nextSibling); 1694 } else { 1695 cur.style.display = ""; 1696 cur.src = account.avatarUrl || ""; 1697 cur.alt = account.avatarUrl ? "" : "(no avatar)"; 1698 } 1699 document.getElementById("ppHandle").textContent = "@" + account.username; 1700 const hint = document.getElementById("ppHint"); 1701 hint.textContent = account.avatarUrl 1702 ? "tap any image below to set as your avatar. your previous avatars stay in history." 1703 : "no avatar set yet â pick one below."; 1704 1705 // History 1706 const histEl = document.getElementById("ppHistory"); 1707 const hist = account.avatarHistory || []; 1708 if (!hist.length) { 1709 histEl.innerHTML = `<div class="pp-grid-empty">no past avatars yet.</div>`; 1710 } else { 1711 histEl.innerHTML = hist.map(url => 1712 `<div class="pp-cell${url === account.avatarUrl ? " current" : ""}" data-url="${esc(url)}" style="background-image:url(${esc(url)})"></div>` 1713 ).join(""); 1714 } 1715 // grid click handling is delegated at the panel root (see below). 1716 1717 // Liked parascene creations 1718 const likesEl = document.getElementById("ppLikes"); 1719 const likesHint = document.getElementById("ppLikesHint"); 1720 if (!account.hasParasceneKey) { 1721 likesEl.innerHTML = ""; 1722 likesHint.textContent = "set /key psn_⦠to see your parascene creations here."; 1723 return; 1724 } 1725 likesHint.textContent = "loadingâ¦"; 1726 likesEl.innerHTML = ""; 1727 try { 1728 const r = await fetch("/api/account/parascene-likes"); 1729 const j = await r.json(); 1730 if (!r.ok) throw new Error(j.error || "failed"); 1731 if (!j.items?.length) { 1732 likesEl.innerHTML = `<div class="pp-grid-empty">no parascene creations yet.</div>`; 1733 likesHint.textContent = j.note || ""; 1734 return; 1735 } 1736 likesEl.innerHTML = j.items.map(it => { 1737 const src = `/api/parascene/image/${encodeURIComponent(it.id)}`; 1738 return `<div class="pp-cell" data-parascene-id="${esc(it.id)}" style="background-image:url(${esc(src)})"></div>`; 1739 }).join(""); 1740 likesHint.textContent = `${j.items.length} creations (newest first).`; 1741 } catch (e) { 1742 likesEl.innerHTML = `<div class="pp-grid-empty">couldn't load likes: ${esc(e.message)}</div>`; 1743 likesHint.textContent = ""; 1744 } 1745} 1746 1747// Single delegated click handler for the profile grids. Bound once to the 1748// panel root â no new listeners per render. Grids are re-populated by 1749// renderProfilePanel; their children inherit this one handler. 1750profilePanelEl.addEventListener("click", async (e) => { 1751 const cell = e.target.closest(".pp-cell"); 1752 if (!cell) return; 1753 let url = cell.dataset.url; 1754 if (!url && cell.dataset.parasceneId) { 1755 cell.style.opacity = "0.5"; 1756 const importRes = await fetch("/api/parascene/import", { 1757 method: "POST", 1758 headers: { "Content-Type": "application/json" }, 1759 body: JSON.stringify({ parascene_id: cell.dataset.parasceneId }), 1760 }); 1761 const imp = await importRes.json(); 1762 cell.style.opacity = ""; 1763 if (!importRes.ok) { alert("import failed: " + (imp.error || "unknown")); return; } 1764 url = imp.image_url; 1765 } 1766 if (!url) return; 1767 const r = await fetch("/api/account/avatar", { 1768 method: "POST", 1769 headers: { "Content-Type": "application/json" }, 1770 body: JSON.stringify({ url }), 1771 }); 1772 const j = await r.json().catch(() => ({})); 1773 if (!r.ok) { alert("couldn't set avatar: " + (j.error || "unknown")); return; } 1774 await loadAccount(); 1775 renderProfilePanel(); 1776}); 1777 1778// Lightbox â fullscreen image with parascene integration: 1779// - publish toggle (minimal gray text button) 1780// - "view on parascene" link (new tab) 1781// - comments panel pulled from parascene /activity 1782// - comment input (only usable with a parascene API key saved) 1783//
1784// Layout: flex row on desktop (cmts L, image center, input R). Stacked on 1785// mobile (input top, image middle, cmts bottom). Pure CSS via media query. 1786function openLightbox(src, genId = null) { 1787 const overlay = document.createElement("div"); 1788 overlay.className = "lightbox"; 1789 overlay.setAttribute("role", "dialog"); 1790 overlay.setAttribute("aria-modal", "true"); 1791 overlay.innerHTML = ` 1792 <button class="lb-close" type="button" aria-label="close">Ã</button> 1793 <div class="lb-layout"> 1794 <aside class="lb-panel lb-comments" aria-label="comments"> 1795 <div class="lb-panel-title">comments</div> 1796 <div class="lb-comments-list"></div> 1797 </aside> 1798 <div class="lb-image-wrap"> 1799 <img class="lb-image" alt=""> 1800 <div class="lb-char-overlay"></div> 1801 <div class="lb-controls"></div> 1802 </div> 1803 <aside class="lb-panel lb-conjure" aria-label="characters"> 1804 <div class="lb-panel-title">characters</div> 1805 <div class="lb-char-list"><div class="lb-empty lb-char-status">loadingâ¦</div></div> 1806 </aside> 1807 <aside class="lb-panel lb-composer" aria-label="add comment"> 1808 <div class="lb-panel-title">add comment</div> 1809 <form class="lb-comment-form"> 1810 <textarea class="lb-comment-input" placeholder="say something on parasceneâ¦" rows="3"></textarea> 1811 <button type="submit" class="lb-comment-send">send</button> 1812 </form> 1813 <div class="lb-comment-hint"></div> 1814 </aside> 1815 </div>`; 1816 1817 const img = overlay.querySelector(".lb-image"); 1818 img.src = src; 1819 1820 const close = overlay.querySelector(".lb-close"); 1821 const commentsList = overlay.querySelector(".lb-comments-list"); 1822 const controls = overlay.querySelector(".lb-controls"); 1823 const commentForm = overlay.querySelector(".lb-comment-form"); 1824 const commentInput = overlay.querySelector(".lb-comment-input"); 1825 const commentHint = overlay.querySelector(".lb-comment-hint"); 1826 1827 const onKey = (e) => { if (e.key === "Escape") dismiss(); }; 1828 const dismiss = () => { 1829 overlay.remove(); 1830 document.removeEventListener("keydown", onKey); 1831 }; 1832 overlay.addEventListener("click", (e) => { 1833 if (e.target === overlay) dismiss(); 1834 }); 1835 close.addEventListener("click", dismiss); 1836 img.addEventListener("click", (e) => e.stopPropagation()); 1837 document.addEventListener("keydown", onKey); 1838 1839 document.body.appendChild(overlay); 1840 1841 // Without a gen_id there's no parascene creation attached (e.g. kamaji 1842 // or bot avatars before backfill). Hide parascene-coupled panels. 1843 if (!genId || !/^\d+$/.test(String(genId))) { 1844 overlay.querySelector(".lb-comments").hidden = true; 1845 overlay.querySelector(".lb-composer").hidden = true; 1846 overlay.querySelector(".lb-conjure").hidden = true; 1847 return; 1848 } 1849 1850 // âââââ characters panel: tap-to-conjure inline âââââ 1851 // Shows a bbox marker on the image per detected character and a card 1852 // in the side panel with either a "conjure" button or a "live as 1853 // @slug" badge if that character is already a bot. 1854 const charOverlay = overlay.querySelector(".lb-char-overlay"); 1855 const charList = overlay.querySelector(".lb-char-list"); 1856 const charStatus = overlay.querySelector(".lb-char-status"); 1857 async function loadCharacters({ allowAnalyze } = { allowAnalyze: true }) { 1858 const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/characters`); 1859 if (r.status === 404 && allowAnalyze) { 1860 // No analysis on disk yet. Kick one and retry once. 1861 charStatus.textContent = "analyzingâ¦"; 1862 const a = await fetch(`/api/gen/${encodeURIComponent(genId)}/analyze`, { method: "POST" }); 1863 if (!a.ok) { 1864 const j = await a.json().catch(() => ({})); 1865 charStatus.textContent = j.error === "budget_exhausted" 1866 ? "out of daily budget" 1867 : "couldn't analyze this image"; 1868 return; 1869 } 1870 return loadCharacters({ allowAnalyze: false }); 1871 } 1872 const j = await r.json().catch(() => ({})); 1873 if (!r.ok || !j.characters?.length) { 1874 charStatus.textContent = "no characters detected in this image"; 1875 return; 1876 } 1877 renderCharacters(j.characters); 1878 } 1879 function renderCharacters(chars) { 1880 // Dot markers over the image. 1881 charOverlay.innerHTML = chars.map((c, i) => { 1882 if (!c.bbox) return ""; 1883 const cx = (c.bbox.x + c.bbox.w / 2) * 100; 1884 const cy = (c.bbox.y + c.bbox.h / 2) * 100; 1885 const cls = c.conjured_as ? " lb-char-dot-live" : ""; 1886 return `<button type="button" class="lb-char-dot${cls}" data-idx="${i}" style="left:${cx.toFixed(1)}%;top:${cy.toFixed(1)}%;">${i + 1}</button>`; 1887 }).join(""); 1888 // Cards in the side panel. 1889 charList.innerHTML = chars.map((c, i) => { 1890 const name = esc(c.conjured_as?.name || c.name_hint || `character ${i + 1}`); 1891 const desc = esc(c.generic_description || ""); 1892 if (c.conjured_as) { 1893 const bio = esc(c.conjured_as.backstory || ""); 1894 return `<div class="lb-char-card live" data-idx="${i}"> 1895 <div class="lb-char-head"><span class="lb-char-num">${i + 1}</span> @${name} <span class="lb-char-badge">live</span></div> 1896 ${bio ? `<div class="lb-char-bio">${bio}</div>` : ""} 1897 </div>`; 1898 } 1899 return `<div class="lb-char-card" data-idx="${i}"> 1900 <div class="lb-char-head"><span class="lb-char-num">${i + 1}</span> ${name}</div> 1901 ${desc ? `<div class="lb-char-bio">${desc}</div>` : ""} 1902 <button type="button" class="btn lb-char-conjure" data-idx="${i}">
1902conjure</button> 1903 </div>`; 1904 }).join(""); 1905 } 1906 async function doConjure(idx, btn) { 1907 const originalText = btn ? btn.textContent : ""; 1908 if (btn) { btn.disabled = true; btn.textContent = "conjuringâ¦"; } 1909 try { 1910 const r = await fetch("/api/conjure/create", { 1911 method: "POST", 1912 headers: { "Content-Type": "application/json" }, 1913 body: JSON.stringify({ character_index: idx, gen_id: genId }), 1914 }); 1915 const j = await r.json().catch(() => ({})); 1916 if (!r.ok) { 1917 const msg = j.hint || j.error || j.message || "unknown"; 1918 if (btn) { btn.textContent = "failed: " + msg; setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2500); } 1919 return; 1920 } 1921 // Re-render so the now-conjured character flips to "live". 1922 loadCharacters({ allowAnalyze: false }); 1923 } catch (e) { 1924 if (btn) { btn.textContent = "error"; setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2500); } 1925 } 1926 } 1927 overlay.addEventListener("click", (e) => { 1928 const conjureBtn = e.target.closest(".lb-char-conjure"); 1929 if (conjureBtn) { 1930 e.stopPropagation(); 1931 doConjure(parseInt(conjureBtn.dataset.idx, 10), conjureBtn); 1932 return; 1933 } 1934 const dot = e.target.closest(".lb-char-dot"); 1935 if (dot) { 1936 e.stopPropagation(); 1937 // Scroll the matching card into view + highlight briefly. 1938 const idx = dot.dataset.idx; 1939 const card = overlay.querySelector(`.lb-char-card[data-idx="${idx}"]`); 1940 if (card) { 1941 card.scrollIntoView({ behavior: "smooth", block: "center" }); 1942 card.classList.add("lb-char-flash"); 1943 setTimeout(() => card.classList.remove("lb-char-flash"), 800); 1944 } 1945 } 1946 }); 1947 loadCharacters().catch(e => { charStatus.textContent = "load error"; console.warn("[lb-conjure]", e); }); 1948 1949 // âââââ controls row (publish toggle + open-on-parascene) âââââ 1950 const hasKey = !!account?.hasParasceneKey; 1951 const pubBtn = document.createElement("button"); 1952 pubBtn.type = "button"; 1953 pubBtn.className = "lb-mini-btn lb-publish"; 1954 pubBtn.textContent = "publish on parascene"; 1955 pubBtn.disabled = !hasKey; 1956 if (!hasKey) pubBtn.title = "set /key psn_⦠to toggle publish"; 1957 1958 const openBtn = document.createElement("a"); 1959 openBtn.className = "lb-mini-btn lb-open"; 1960 openBtn.textContent = "view on parascene â"; 1961 openBtn.target = "_blank"; 1962 openBtn.rel = "noopener noreferrer"; 1963 // If the viewer has a linked parascene account, prefer the canonical 1964 // /creations/<id> URL â readable, stable, and parascene logs them in 1965 // automatically. Otherwise fall back to the sh.parascene share URL 1966 // (which we lazy-fetch below) so anonymous viewers can still see it. 1967 if (hasKey && genId) { 1968 openBtn.href = `https://www.parascene.com/creations/${encodeURIComponent(genId)}`; 1969 } else { 1970 openBtn.href = "#"; 1971 } 1972 1973 controls.appendChild(pubBtn); 1974 controls.appendChild(openBtn); 1975 1976 // Hide composer if no parascene key. 1977 if (!hasKey) { 1978 commentInput.disabled = true; 1979 commentInput.placeholder = "set /key psn_⦠to comment"; 1980 overlay.querySelector(".lb-comment-send").disabled = true; 1981 commentHint.textContent = "no parascene key â can view comments, can't post."; 1982 } 1983 1984 // For anonymous viewers (no parascene key linked) the only working 1985 // URL is the sh.parascene share permalink. With a linked key we 1986 // already point at /creations/<id> above and don't bother minting 1987 // a share URL. 1988 if (!hasKey) { 1989 // Show button anyway â server-side resolves the share URL. 1990 fetch(`/api/gen/${encodeURIComponent(genId)}/public-share`) 1991 .then(r => r.json()) 1992 .then(j => { if (j.ok && j.url) openBtn.href = j.url; else openBtn.hidden = true; }) 1993 .catch(() => { openBtn.hidden = true; }); 1994 } 1995 1996 // âââââ publish toggle âââââ 1997 async function togglePublish() { 1998 const pressed = pubBtn.getAttribute("aria-pressed") === "true"; 1999 const want = !pressed; 2000 pubBtn.disabled = true; 2001 try { 2002 const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/publish`, { 2003 method: "POST", 2004 headers: { "Content-Type": "application/json" }, 2005 body: JSON.stringify({ published: want }), 2006 }); 2007 const j = await r.json().catch(() => ({})); 2008 if (!r.ok) { 2009 pubBtn.textContent = "publish failed"; 2010 setTimeout(() => { pubBtn.textContent = pressed ? "unpublish" : "publish on parascene"; }, 1500); 2011 return; 2012 } 2013 const now = !!j.published; 2014 pubBtn.setAttribute("aria-pressed", now ? "true" : "false"); 2015 pubBtn.textContent = now ? "unpublish from parascene" : "publish on parascene"; 2016 } finally { 2017 pubBtn.disabled = false;
2018 } 2019 } 2020 pubBtn.addEventListener("click", togglePublish); 2021 2022 // âââââ comments loader âââââ 2023 async function loadComments() { 2024 if (!hasKey) { commentsList.innerHTML = "<div class=\"lb-empty\">sign in with a parascene key to see comments</div>"; return; } 2025 commentsList.innerHTML = "<div class=\"lb-empty\">loadingâ¦</div>"; 2026 try { 2027 const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/activity`); 2028 const j = await r.json(); 2029 if (!r.ok) throw new Error(j.error || "failed"); 2030 const comments = (j.items || []).filter(it => it.type === "comment" || it.body || it.text); 2031 if (!comments.length) { 2032 commentsList.innerHTML = "<div class=\"lb-empty\">no comments yet</div>"; 2033 return; 2034 } 2035 commentsList.innerHTML = comments.map(c => { 2036 const who = c.user?.user_name || c.author_user_name || c.user_name || "?"; 2037 const text = c.text || c.body || ""; 2038 return `<div class="lb-comment"><span class="lb-comment-who">@${esc(who)}</span> <span class="lb-comment-body">${esc(text)}</span></div>`; 2039 }).join(""); 2040 } catch (e) { 2041 commentsList.innerHTML = `<div class="lb-empty">couldn't load: ${esc(e.message)}</div>`; 2042 } 2043 } 2044 loadComments(); 2045 2046 // âââââ comment post âââââ 2047 commentForm.addEventListener("submit", async (e) => { 2048 e.preventDefault(); 2049 if (!hasKey) return; 2050 const text = commentInput.value.trim(); 2051 if (!text) return; 2052 const sendBtn = overlay.querySelector(".lb-comment-send"); 2053 sendBtn.disabled = true; sendBtn.textContent = "â¦"; 2054 try { 2055 const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/comment`, { 2056 method: "POST", 2057 headers: { "Content-Type": "application/json" }, 2058 body: JSON.stringify({ text }), 2059 }); 2060 const j = await r.json().catch(() => ({})); 2061 if (!r.ok) { 2062 commentHint.textContent = "send failed: " + (j.error || "unknown"); 2063 return; 2064 } 2065 commentInput.value = ""; 2066 commentHint.textContent = "posted."; 2067 loadComments(); 2068 } finally { 2069 sendBtn.disabled = false; sendBtn.textContent = "send"; 2070 } 2071 }); 2072} 2073 2074// âââââââââ profile panel (myspace-style) âââââââââ 2075// Expands the avatar-click flow from a bare image lightbox into a full 2076// profile page: bio, wall, guestbook, inline DM composer, and a 2077// sandboxed iframe where the target's custom HTML/CSS/JS runs. Same 2078// treatment for humans and bots; the server figures out which kind the 2079// target is. When the viewer is the target (or an admin or bot owner), 2080// an edit button appears. 2081async function openProfilePanel(target) { 2082 if (!target) return; 2083 // Close any currently open profile panel before opening another. 2084 document.querySelector(".profile-panel")?.remove(); 2085 2086 const overlay = document.createElement("div"); 2087 overlay.className = "profile-panel"; 2088 overlay.setAttribute("role", "dialog"); 2089 overlay.setAttribute("aria-modal", "true"); 2090 overlay.innerHTML = ` 2091 <button class="pp2-close" type="button" aria-label="close">Ã</button> 2092 <div class="pp2-sheet"> 2093 <aside class="pp2-col pp2-info"> 2094 <div class="pp2-avatar"></div> 2095 <div class="pp2-name"></div> 2096 <div class="pp2-meta"></div> 2097 <div class="pp2-bio"></div> 2098 <div class="pp2-botinfo"></div> 2099 <div class="pp2-top-friends"></div> 2100 <div class="pp2-audio"></div> 2101 </aside> 2102 <main class="pp2-col pp2-center"> 2103 <div class="pp2-custom"></div> 2104 <div class="pp2-dm"> 2105 <div class="pp2-section-title">dm</div> 2106 <div class="pp2-dm-thread"></div> 2107 <form class="pp2-dm-form"> 2108 <textarea class="pp2-dm-input" rows="2" placeholder="dmâ¦"></textarea> 2109 <button type="submit" class="pp2-btn pp2-dm-send">send</button> 2110 </form> 2111 </div> 2112 </main> 2113 <aside class="pp2-col pp2-social"> 2114 <div class="pp2-section-title">wall</div> 2115 <form class="pp2-wall-form"> 2116 <textarea class="pp2-wall-input" rows="2" placeholder="post to the wallâ¦"></textarea> 2117 <button type="submit" class="pp2-btn">post</button> 2118 </form> 2119 <div class="pp2-wall-list"></div> 2120 <div class="pp2-section-title">guestbook</div> 2121 <form class="pp2-guest-form"> 2122 <input type="text" class="pp2-guest-input" maxlength="500" placeholder="sign the guestbookâ¦"> 2123 <button type="submit" class="pp2-btn">sign</button> 2124 </form> 2125 <div class="pp2-guest-list"></div> 2126 </aside> 2127 </div> 2128 <button class="pp2-edit" type="button" hidden>edit profile</button> 2129 <button class="pp2-signout" type="button" hidden>sign out</button>`; 2130 2131 const dismiss = () => { overlay.remove(); document.removeEventListener("keydown", onKey); }; 2132 const onKey = (e) => { if (e.key === "Escape") dismiss(); }; 2133 document.addEventListener("keydown", onKey); 2134 overlay.addEventListener("click", (e) => { 2135 if (e.target === overlay) dismiss(); 2136 }); 2137 overlay.querySelector(".pp2-close").addEventListener("click", dismiss); 2138 2139 document.body.appendChild(overlay); 2140 2141 // Fetch and render. 2142 const r = await fetch(`/api/profile/${encodeURIComponent(target)}`); 2143 const data = await r.json().catch(() => ({})); 2144 if (!r.ok || !data.ok) { 2145 overlay.querySelector(".pp2-sheet").innerHTML = 2146 `<div class="pp2-empty">couldn't load @${esc(target)}: ${esc(data.error || "error")}</div>`; 2147 return; 2148 } 2149 renderProfileData(overlay, data); 2150} 2151 2152async function renderProfileData(overlay, data) { 2153 const { 2154 kind, id, username, avatarUrl, bio, customHtml, customCss, 2155 backgroundUrl, audioUrl, wall, guestbook, canEdit, botInfo, 2156 } = data; 2157 2158 const avatar = overlay.querySelector(".pp2-avatar"); 2159 if (avatarUrl) { 2160 if (isVideoAvatar(avatarUrl)) { 2161 avatar.classList.add("pp2-avatar-video"); 2162 avatar.innerHTML = `<video src="${esc(avatarUrl)}" data-autoloop="1" muted loop playsinline preload="metadata"></video>`; 2163 } else { 2164 avatar.style.backgroundImage = `url(${encodeURI(avatarUrl)})`; 2165 } 2166 } else { 2167 avatar.classList.add("pp2-avatar-empty"); 2168 } 2169 2170 overlay.querySelector(".pp2-name").textContent = "@" + username; 2171 2172 const meta = overlay.querySelector(".pp2-meta"); 2173 if (kind === "bot") { 2174 const parts = []; 2175 if (botInfo?.creatorUsername) parts.push(`conjured by @${botInfo.creatorUsername}`); 2176 if (botInfo?.genId) parts.push(`source /gen ${botInfo.genId}`); 2177 if (botInfo?.state && botInfo.state !== "alive") parts.push(botInfo.state); 2178 meta.textContent = parts.join(" · "); 2179 } else { 2180 meta.textContent = "human"; 2181 } 2182 2183 const bioEl = overlay.querySelector(".pp2-bio"); 2184 bioEl.textContent = bio || (kind === "bot" && botInfo?.backstory) || ""; 2185 2186 const botEl = overlay.querySelector(".pp2-botinfo"); 2187 if (kind === "bot" && botInfo) { 2188 botEl.innerHTML = ""; 2189 const addRow = (label, value) => { 2190 if (!value) return;
2191 const row = document.createElement("div"); 2192 row.className = "pp2-botinfo-row"; 2193 row.innerHTML = `<span class="pp2-botinfo-label">${esc(label)}</span> <span class="pp2-botinfo-val">${esc(value)}</span>`; 2194 botEl.appendChild(row); 2195 }; 2196 addRow("voice", botInfo.voice); 2197 addRow("signature", botInfo.signatureMove); 2198 addRow("from scene", botInfo.birthCharacter); 2199 if (botInfo.genId) { 2200 const row = document.createElement("div"); 2201 row.className = "pp2-botinfo-row"; 2202 row.innerHTML = `<a class="pp2-source-link" href="#" data-gen="${esc(String(botInfo.genId))}">view source image â</a>`; 2203 row.querySelector("a").addEventListener("click", (ev) => { 2204 ev.preventDefault(); 2205 const src = botInfo.imageUrl || `/api/parascene/thumb/${encodeURIComponent(botInfo.genId)}`; 2206 openLightbox(src, botInfo.genId); 2207 }); 2208 botEl.appendChild(row); 2209 } 2210 } 2211 2212 // Top friends grid. Each entry is a mini avatar tile that opens 2213 // its own profile on click. Server resolves names â public summary 2214 // server-side so we already have avatarUrl resolved per entry. 2215 const tfEl = overlay.querySelector(".pp2-top-friends"); 2216 const tf = data.topFriends || []; 2217 if (tf.length) { 2218 tfEl.innerHTML = ` 2219 <div class="pp2-section-title">top ${tf.length}</div> 2220 <div class="pp2-top-friends-grid"> 2221 ${tf.map(f => ` 2222 <a class="pp2-tf-cell" data-who="${esc(f.name)}" title="@${esc(f.name)}"> 2223 ${f.avatarUrl ? `<div class="pp2-tf-img" style="background-image:url(${esc(f.avatarUrl)})"></div>` : `<div class="pp2-tf-img pp2-tf-empty"></div>`} 2224 <div class="pp2-tf-name">@${esc(f.name)}</div> 2225 </a> 2226 `).join("")} 2227 </div>`; 2228 tfEl.querySelectorAll(".pp2-tf-cell").forEach(a => { 2229 a.addEventListener("click", (ev) => { 2230 ev.preventDefault(); 2231 openProfilePanel(a.dataset.who); 2232 }); 2233 }); 2234 } 2235 2236 // Audio: simple built-in player. Doesn't autoplay (browsers block 2237 // without a gesture) but hitting play works. 2238 if (audioUrl) { 2239 overlay.querySelector(".pp2-audio").innerHTML = 2240 `<audio controls loop preload="none" class="pp2-audio-el" src="${esc(audioUrl)}"></audio> 2241 <div class="pp2-audio-caption">now playing</div>`; 2242 } 2243 2244 // Background on the sheet body if set. 2245 if (backgroundUrl) { 2246 overlay.querySelector(".pp2-sheet").style.backgroundImage = `url(${encodeURI(backgroundUrl)})`; 2247 } 2248 2249 // FULLBLEED MODE: customHtml has data-slot="X" markers â render 2250 // it as the OUTER container with chrome injected at slot positions. 2251 // The pp2-info / pp2-center / pp2-social columns of the panel are 2252 // hidden; the iframe takes the whole sheet. 2253 const fullbleed = profileHasSlots(customHtml); 2254 if (fullbleed) { 2255 overlay.classList.add("pp2-fullbleed"); 2256 // Pull the DM thread for this viewerâpartner pair so the dm 2257 // slot can render the full thread immediately. Independent of 2258 // the chrome's existing dm wiring (that's hidden in fullbleed). 2259 let dmMessages = []; 2260 if (account?.username && account.username.toLowerCase() !== username.toLowerCase()) { 2261 try { 2262 const dr = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`); 2263 if (dr.ok) dmMessages = (await dr.json()).messages || []; 2264 } catch {} 2265 } 2266 const slots = renderSlots({ ...data, dmMessages, viewerName: account?.username || "" }); 2267 const stamped = stampSlots(customHtml, slots); 2268 const iframe = document.createElement("iframe"); 2269 iframe.className = "pp2-custom-frame pp2-custom-fullbleed"; 2270 iframe.setAttribute("sandbox", "allow-scripts"); 2271 iframe.srcdoc = buildCustomSrcdoc(stamped, customCss || "", data.customFonts || ""); 2272 // Replace the entire sheet with the iframe. 2273 const sheet = overlay.querySelector(".pp2-sheet"); 2274 sheet.innerHTML = ""; 2275 sheet.appendChild(iframe); 2276 if (backgroundUrl) sheet.style.backgroundImage = `url(${encodeURI(backgroundUrl)})`; 2277 2278 // Parent-side bridge: handle form submits postMessaged from 2279 // inside the iframe. Each handler does the API call, then 2280 // postMessages the iframe an updated slot HTML. 2281 const updateSlot = (slot, html) => { 2282 try { iframe.contentWindow.postMessage({ slop: "update", slot, html }, "*"); } catch {} 2283 }; 2284 const refresh = async () => { 2285 try { 2286 const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}`); 2287 if (!r2.ok) return null; 2288 return await r2.json(); 2289 } catch { return null; } 2290 }; 2291 const onMsg = async (ev) => { 2292 if (ev.source !== iframe.contentWindow) return; 2293 const m = ev.data; 2294 if (!m || m.slop !== "form") return; 2295 if (m.action === "open-profile" && m.data?.target) { 2296 openProfilePanel(m.data.target); 2297 return; 2298 } 2299 const text = String(m.data?.text || "").trim(); 2300 if (!text) return; 2301 try { 2302 if (m.action === "wall.post") { 2303 const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}/wall`, { 2304 method: "POST", headers: { "Content-Type": "application/json" }, 2305 body: JSON.stringify({ text }), 2306 }); 2307 if (!r2.ok) return; 2308 const fresh = await refresh(); 2309 if (fresh) updateSlot("wall", renderSlotWall(fresh.wall || [])); 2310 } else if (m.action === "guest.post") { 2311 const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}/guestbook`, { 2312 method: "POST", headers: { "Content-Type": "application/json" }, 2313 body: JSON.stringify({ text }), 2314 }); 2315 if (!r2.ok) return; 2316 const fresh = await refresh(); 2317 if (fresh) updateSlot("guestbook", renderSlotGuest(fresh.guestbook || [])); 2318 } else if (m.action === "dm.post") { 2319 if (!account?.username) return; 2320 const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`, { 2321 method: "POST", headers: { "Content-Type": "application/json" }, 2322 body: JSON.stringify({ text }), 2323 }); 2324 if (!r2.ok) return; 2325 const dr = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`); 2326 if (dr.ok) { 2327 const j = await dr.json(); 2328 updateSlot("dm", renderSlotDm(j.messages || [], account.username, username)); 2329 } 2330 } 2331 } catch {} 2332 }; 2333 window.addEventListener("message", onMsg); 2334 // Live dm-msg arrival â re-render dm slot. Listens on document 2335 // so WS reconnects don't break delivery. 2336 const wsListener = async (wev) => { 2337 const mm = wev.detail; 2338 if (!mm || mm.t !== "dm-msg" || !mm.msg) return; 2339 const partnerKey = (username || "").toLowerCase(); 2340 const fromKey = (mm.msg.from || "").toLowerCase(); 2341 const toKey = (mm.msg.to || "").toLowerCase(); 2342 if (fromKey !== partnerKey && toKey !== partnerKey) return; 2343 try { 2344 const dr = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`); 2345 if (!dr.ok) return; 2346 const j = await dr.json(); 2347 updateSlot("dm", renderSlotDm(j.messages || [], account?.username || "", username)); 2348 } catch {} 2349 }; 2350 document.addEventListener("slop:ws-msg", wsListener); 2351 // Tear down when overlay closes. 2352 const cleanup = setInterval(() => { 2353 if (!document.body.contains(overlay)) { 2354 window.removeEventListener("message", onMsg); 2355 try { document.removeEventListener("slop:ws-msg", wsListener); } catch {} 2356 clearInterval(cleanup); 2357 } 2358 }, 1000); 2359 // Skip the rest of the legacy chrome render â fullbleed owns 2360 // the whole sheet. 2361 return; 2362 } 2363 2364 // Custom HTML/CSS/JS rendered inside a sandboxed iframe. Scripts 2365 // are allowed (so cursor-followers etc work) but NOT same-origin, 2366 // so nothing in there can read parent cookies or localStorage. 2367 // Building the iframe via DOM (not innerHTML) so we can set srcdoc 2368 // as a property â avoids needing to escape quotes in user content 2369 // for the HTML attribute context. 2370 const customEl = overlay.querySelector(".pp2-custom"); 2371 if (customHtml || customCss || data.customFonts) { 2372 const iframe = document.createElement("iframe"); 2373 iframe.className = "pp2-custom-frame"; 2374 iframe.setAttribute("sandbox", "allow-scripts"); 2375 iframe.srcdoc = buildCustomSrcdoc(customHtml || "", customCss || "", data.customFonts || ""); 2376 customEl.appendChild(iframe); 2377 } 2378 2379 // DM thread â two-way, persisted. Render existing messages, then 2380 // wire the composer to POST and live updates via dm-msg WS frame. 2381 // Dedup by msg.id: server returns the id on POST, WS broadcast carries 2382 // the same id, so we never double-render. 2383 const threadEl = overlay.querySelector(".pp2-dm-thread"); 2384 const renderedDmIds = new Set(); 2385 const renderDmMsg = (m) => { 2386 if (m.id && renderedDmIds.has(m.id)) return; 2387 if (m.id) renderedDmIds.add(m.id);
2388 const row = document.createElement("div"); 2389 row.className = "pp2-dm-msg"; 2390 row.dataset.from = m.from || ""; 2391 if (m.id) row.dataset.id = m.id; 2392 const mine = account?.username && (m.from || "").toLowerCase() === account.username.toLowerCase(); 2393 if (mine) row.classList.add("pp2-dm-mine"); 2394 const attachHtml = m.attach_video 2395 ? `<video class="pp2-dm-attach" src="${esc(m.attach_video)}" width="1024" height="1024" data-autoloop="1" muted loop playsinline preload="metadata"></video>` 2396 : m.attach 2397 ? `<img class="pp2-dm-attach" src="${esc(m.attach)}" width="1024" height="1024" alt="">` 2398 : ""; 2399 // Delete X â visible only on hover; admin OR self-author may delete. 2400 const canDelete = !!(m.id && (account?.isAdmin || mine)); 2401 const delBadge = canDelete ? `<button class="pp2-dm-del" type="button" title="delete" data-id="${esc(m.id)}">Ã</button>` : ""; 2402 // Rewrote badge â peach ð indicating the bot toned down the 2403 // user's request rather than refusing. Visible only on hover. 2404 const rewroteBadge = m.rewrote ? `<span class="pp2-dm-rewrote" title="reply was softened from your original phrasing">ð</span>` : ""; 2405 row.innerHTML = `<span class="pp2-dm-from">${esc(m.from)}</span><span class="pp2-dm-text">${linkify(esc(m.text || ""))}${attachHtml}</span>${delBadge}${rewroteBadge}`; 2406 threadEl.appendChild(row); 2407 threadEl.scrollTop = threadEl.scrollHeight; 2408 }; 2409 // Delegated delete handler â hover-X click removes the row + DELETE on server. 2410 threadEl.addEventListener("click", async (e) => { 2411 const btn = e.target.closest(".pp2-dm-del"); 2412 if (!btn) return; 2413 e.preventDefault(); e.stopPropagation(); 2414 const id = btn.dataset.id; 2415 if (!id) return; 2416 btn.disabled = true; 2417 try { 2418 const r = await fetch(`/api/profile/${encodeURIComponent(username)}/dm/${encodeURIComponent(id)}`, { method: "DELETE" }); 2419 if (!r.ok) { btn.disabled = false; return; } 2420 btn.closest(".pp2-dm-msg")?.remove(); 2421 } catch { btn.disabled = false; } 2422 }); 2423 if (account?.username && username && account.username.toLowerCase() !== username.toLowerCase()) { 2424 try { 2425 const r = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`); 2426 if (r.ok) { 2427 const j = await r.json(); 2428 threadEl.innerHTML = ""; 2429 for (const m of (j.messages || [])) renderDmMsg(m); 2430 } 2431 } catch {} 2432 } else { 2433 // self-DM not supported. 2434 threadEl.innerHTML = `<div class="pp2-dm-empty">(can't dm yourself)</div>`; 2435 } 2436 const dmForm = overlay.querySelector(".pp2-dm-form"); 2437 dmForm.addEventListener("submit", async (ev) => { 2438 ev.preventDefault(); 2439 const input = overlay.querySelector(".pp2-dm-input"); 2440 const text = input.value.trim(); 2441 if (!text) return; 2442 if (!account?.username) { alert("sign in to dm"); return; } 2443 if (account.username.toLowerCase() === username.toLowerCase()) return; 2444 const r = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`, { 2445 method: "POST", 2446 headers: { "Content-Type": "application/json" }, 2447 body: JSON.stringify({ text }), 2448 }); 2449 if (!r.ok) return; 2450 const j = await r.json().catch(() => ({})); 2451 if (j.msg) renderDmMsg(j.msg); 2452 input.value = ""; 2453 }); 2454 // Live dm-msg arrivals + dm-typing transient indicator. Both pin 2455 // to the bottom of the thread; typing rows replace any prior typing 2456 // row from the same bot, and any dm-msg arrival clears typing. 2457 // Listens on document so WS reconnects don't break delivery â 2458 // the WS dispatches each frame as a slop:ws-msg event regardless 2459 // of which underlying socket received it. 2460 const dmListener = (ev) => { 2461 const m = ev.detail; 2462 if (!m) return; 2463 const partnerKey = (username || "").toLowerCase(); 2464 if (m.t === "dm-typing" && m.who && m.who.toLowerCase() === partnerKey) { 2465 // Drop any prior typing row from this bot. 2466 threadEl.querySelector(`.pp2-dm-typing[data-who="${m.who.toLowerCase()}"]`)?.remove();
2467 const row = document.createElement("div"); 2468 row.className = "pp2-dm-msg pp2-dm-typing"; 2469 row.dataset.who = m.who.toLowerCase(); 2470 row.innerHTML = `<span class="pp2-dm-from">${esc(m.who)}</span><span class="pp2-dm-text"><em>${esc(m.hint || "thinkingâ¦")}</em></span>`; 2471 threadEl.appendChild(row); 2472 threadEl.scrollTop = threadEl.scrollHeight; 2473 return; 2474 } 2475 if (m.t !== "dm-msg" || !m.msg) return; 2476 const fromKey = (m.msg.from || "").toLowerCase(); 2477 const toKey = (m.msg.to || "").toLowerCase(); 2478 if (fromKey !== partnerKey && toKey !== partnerKey) return; 2479 // Clear any active typing indicator from this side. 2480 threadEl.querySelector(`.pp2-dm-typing[data-who="${fromKey}"]`)?.remove(); 2481 renderDmMsg(m.msg); 2482 }; 2483 document.addEventListener("slop:ws-msg", dmListener); 2484 // Detach when overlay closes â overlay teardown happens via the 2485 // close-button click; latch onto its existing close path by 2486 // checking if our element is still in the DOM on each event. 2487 const cleanupCheck = setInterval(() => { 2488 if (!document.body.contains(overlay)) { 2489 try { document.removeEventListener("slop:ws-msg", dmListener); } catch {} 2490 clearInterval(cleanupCheck); 2491 } 2492 }, 1000); 2493 2494 // Wall: render posts + post form. 2495 renderWall(overlay, username, wall || []); 2496 const wallForm = overlay.querySelector(".pp2-wall-form"); 2497 wallForm.addEventListener("submit", async (ev) => { 2498 ev.preventDefault(); 2499 const input = overlay.querySelector(".pp2-wall-input"); 2500 const text = input.value.trim(); 2501 if (!text) return; 2502 const r = await fetch(`/api/profile/${encodeURIComponent(username)}/wall`, { 2503 method: "POST", 2504 headers: { "Content-Type": "application/json" }, 2505 body: JSON.stringify({ text }), 2506 }); 2507 if (r.ok) { 2508 input.value = ""; 2509 // Reload the profile to get the fresh wall state. 2510 const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}`); 2511 const d2 = await r2.json().catch(() => ({})); 2512 if (d2.ok) renderWall(overlay, username, d2.wall || []); 2513 } 2514 }); 2515 2516 // Guestbook. 2517 renderGuestbook(overlay, guestbook || []); 2518 const gForm = overlay.querySelector(".pp2-guest-form"); 2519 gForm.addEventListener("submit", async (ev) => { 2520 ev.preventDefault(); 2521 const input = overlay.querySelector(".pp2-guest-input"); 2522 const text = input.value.trim(); 2523 if (!text) return; 2524 const r = await fetch(`/api/profile/${encodeURIComponent(username)}/guestbook`, { 2525 method: "POST", 2526 headers: { "Content-Type": "application/json" }, 2527 body: JSON.stringify({ text }), 2528 }); 2529 if (r.ok) { 2530 input.value = ""; 2531 const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}`); 2532 const d2 = await r2.json().catch(() => ({})); 2533 if (d2.ok) renderGuestbook(overlay, d2.guestbook || []); 2534 } 2535 }); 2536 2537 // Edit + sign-out buttons. Both only shown on the user's own profile. 2538 const editBtn = overlay.querySelector(".pp2-edit"); 2539 const signoutBtn = overlay.querySelector(".pp2-signout"); 2540 if (canEdit) { 2541 editBtn.hidden = false; 2542 editBtn.addEventListener("click", () => openProfileEditor(username, data)); 2543 signoutBtn.hidden = false; 2544 signoutBtn.addEventListener("click", async () => { 2545 if (!confirm("sign out?")) return; 2546 try { await fetch("/api/auth/signout", { method: "POST" }); } catch {} 2547 location.reload(); 2548 }); 2549 } 2550} 2551 2552function renderWall(overlay, target, posts) { 2553 const list = overlay.querySelector(".pp2-wall-list"); 2554 if (!posts.length) { 2555 list.innerHTML = `<div class="pp2-empty">no posts yet</div>`; 2556 return; 2557 } 2558 list.innerHTML = posts.map(p => ` 2559 <div class="pp2-wall-post"> 2560 <div class="pp2-wall-head"> 2561 <a class="pp2-wall-from" data-who="${esc(p.from)}">@${esc(p.from)}</a>
2562 <span class="pp2-wall-ts">${relTime(p.ts)}</span> 2563 </div> 2564 <div class="pp2-wall-body">${linkify(esc(p.text))}</div> 2565 </div> 2566 `).join(""); 2567 list.querySelectorAll(".pp2-wall-from").forEach(a => { 2568 a.addEventListener("click", (ev) => { 2569 ev.preventDefault(); 2570 openProfilePanel(a.dataset.who); 2571 }); 2572 }); 2573} 2574 2575function renderGuestbook(overlay, entries) { 2576 const list = overlay.querySelector(".pp2-guest-list"); 2577 if (!entries.length) { 2578 list.innerHTML = `<div class="pp2-empty">unsigned</div>`; 2579 return; 2580 } 2581 list.innerHTML = entries.map(e => ` 2582 <div class="pp2-guest-entry"> 2583 <a class="pp2-guest-from" data-who="${esc(e.from)}">@${esc(e.from)}</a> 2584 <span class="pp2-guest-text">${linkify(esc(e.text))}</span> 2585 <span class="pp2-guest-ts">${relTime(e.ts)}</span> 2586 </div> 2587 `).join(""); 2588 list.querySelectorAll(".pp2-guest-from").forEach(a => { 2589 a.addEventListener("click", (ev) => { 2590 ev.preventDefault(); 2591 openProfilePanel(a.dataset.who); 2592 }); 2593 }); 2594} 2595 2596function relTime(ts) { 2597 if (!ts) return ""; 2598 const dt = Date.now() - ts; 2599 if (dt < 60_000) return "just now"; 2600 if (dt < 3600_000) return Math.floor(dt / 60_000) + "m"; 2601 if (dt < 86400_000) return Math.floor(dt / 3600_000) + "h"; 2602 return Math.floor(dt / 86400_000) + "d"; 2603} 2604 2605// Wrap user's custom HTML + CSS in a full HTML document for the iframe. 2606// Adds a minimal default stylesheet so a pure-HTML submission still 2607// reads against dark-slop aesthetic before their own CSS layers on. 2608// fonts is injected as a separate <style> block BEFORE the main CSS so 2609// @import / @font-face rules load first and are available when 2610// customCss references them (font-family referring to them won't flash 2611// fallback). @import in a later style block can be flaky; keeping it 2612// first avoids that. 2613function buildCustomSrcdoc(html, css, fonts) { 2614 const defaultCss = ` 2615 html, body { margin: 0; padding: 12px; background: transparent; color: #d8d8d8; 2616 font: 12px/1.4 -apple-system, Menlo, monospace; overflow: auto; } 2617 a { color: #ffc857; } 2618 img { max-width: 100%; } 2619 `; 2620 const fontStyle = fonts ? `<style>${fonts}</style>` : ""; 2621 return `<!doctype html><html><head><meta charset="utf-8"> 2622${fontStyle} 2623<style>${defaultCss}\n${css || ""}</style> 2624${BRIDGE_SCRIPT} 2625</head><body>${html || ""}</body></html>`; 2626} 2627 2628// âââââââââ profile-invert: slot system inside customHtml iframe âââââââââ 2629// 2630// When customHtml contains any data-slot="X" markers, the profile is 2631// rendered "fullbleed": the iframe becomes the whole panel and the 2632// normal chrome (avatar / bio / wall / guestbook / dm / friends) is 2633// injected INSIDE the iframe at the slot positions the user authored. 2634// 2635// Sandbox stays at allow-scripts only (no allow-same-origin) â to keep 2636// the parent's cookie + localStorage out of reach. Interactivity from 2637// inside the iframe rides over postMessage: 2638// 2639// iframe â parent: { slop: "form", action: "wall.post"|"dm.post"|"guest.post"|"open-profile", 2640// data: { text? | target? } } 2641// parent â iframe: { slop: "update", slot: "wall"|"dm"|â¦, html: "<â¦>" } 2642// 2643// The bridge script below is injected into every srcdoc (slotted or
2644// not) so even non-fullbleed customHtml can postMessage to e.g. open 2645// another profile. It's tiny enough to not affect non-slot pages. 2646const BRIDGE_SCRIPT = `<script> 2647(function () { 2648 try { 2649 document.addEventListener("submit", function (e) { 2650 var form = e.target; 2651 if (!form || !form.dataset || !form.dataset.action) return; 2652 e.preventDefault(); 2653 var data = {}; 2654 for (var el of form.elements) { if (el.name) data[el.name] = el.value; } 2655 parent.postMessage({ slop: "form", action: form.dataset.action, data: data }, "*"); 2656 try { form.reset(); } catch (_) {} 2657 }); 2658 document.addEventListener("click", function (e) { 2659 var a = e.target && e.target.closest && e.target.closest("[data-open-profile]"); 2660 if (!a) return; 2661 e.preventDefault(); 2662 parent.postMessage({ slop: "form", action: "open-profile", data: { target: a.dataset.openProfile } }, "*"); 2663 }); 2664 window.addEventListener("message", function (e) { 2665 var m = e && e.data; 2666 if (!m || m.slop !== "update" || !m.slot) return; 2667 var node = document.querySelector('[data-slot="' + m.slot + '"]'); 2668 if (node) node.innerHTML = m.html; 2669 }); 2670 } catch (_) {} 2671})(); 2672</script>`; 2673 2674function profileHasSlots(html) { 2675 return /data-slot="[a-z][a-z_-]*"/.test(html || ""); 2676} 2677 2678// Render the chrome HTML chunks for each named slot, given the live 2679// profile data. Returned values are HTML STRINGS (sanitized) that get 2680// either stamped into customHtml at server-render time OR posted to 2681// the iframe via postMessage on live updates.
2682function renderSlots(data) { 2683 const u = data.username || ""; 2684 const out = {}; 2685 out.avatar = data.avatarUrl 2686 ? `<img src="${esc(data.avatarUrl)}" alt="${esc(u)}">` 2687 : `<div class="slot-avatar-empty">no avatar</div>`; 2688 out.name = `<span class="slot-name">@${esc(u)}</span>`; 2689 out.bio = (data.bio || "").trim() ? linkify(esc(data.bio)) : `<span class="slot-empty">no bio yet</span>`; 2690 out.audio = data.audioUrl 2691 ? `<audio src="${esc(data.audioUrl)}" controls preload="none"></audio>` 2692 : ""; 2693 out.friends = Array.isArray(data.topFriends) && data.topFriends.length 2694 ? `<ul class="slot-friends">${data.topFriends.map(f => `<li><a href="#" data-open-profile="${esc(f)}">${esc(f)}</a></li>`).join("")}</ul>` 2695 : `<span class="slot-empty">no top friends</span>`; 2696 out.wall = renderSlotWall(data.wall || []); 2697 out.guestbook = renderSlotGuest(data.guestbook || []); 2698 out.dm = renderSlotDm(data.dmMessages || [], data.viewerName || "", u); 2699 return out; 2700} 2701 2702function renderSlotWall(posts) { 2703 const list = posts.length 2704 ? `<ul class="slot-wall-list">${posts.slice(-30).reverse().map(p => ` 2705 <li class="slot-wall-post"> 2706 <span class="slot-wall-from"><a href="#" data-open-profile="${esc(p.from)}">${esc(p.from)}</a></span> 2707 <span class="slot-wall-text">${linkify(esc(p.text || ""))}</span> 2708 </li>`).join("")}</ul>` 2709 : `<div class="slot-empty">empty wall</div>`; 2710 return `${list} 2711 <form class="slot-wall-form" data-action="wall.post"> 2712 <textarea name="text" rows="2" placeholder="post to the wallâ¦"></textarea> 2713 <button type="submit">post</button> 2714 </form>`; 2715}
2716function renderSlotGuest(entries) { 2717 const list = entries.length 2718 ? `<ul class="slot-guest-list">${entries.slice(-30).reverse().map(e => ` 2719 <li class="slot-guest-entry"> 2720 <span class="slot-guest-from"><a href="#" data-open-profile="${esc(e.from)}">${esc(e.from)}</a></span> 2721 <span class="slot-guest-text">${linkify(esc(e.text || ""))}</span> 2722 </li>`).join("")}</ul>` 2723 : `<div class="slot-empty">empty guestbook</div>`; 2724 return `${list} 2725 <form class="slot-guest-form" data-action="guest.post"> 2726 <input type="text" name="text" maxlength="500" placeholder="sign the guestbookâ¦"> 2727 <button type="submit">sign</button> 2728 </form>`; 2729} 2730function renderSlotDm(messages, viewer, partner) { 2731 const same = (a, b) => (a || "").toLowerCase() === (b || "").toLowerCase(); 2732 if (!viewer || same(viewer, partner)) { 2733 return `<div class="slot-empty">(can't dm yourself)</div>`; 2734 } 2735 const list = `<ul class="slot-dm-list">${messages.map(m => ` 2736 <li class="slot-dm-msg ${same(m.from, viewer) ? "slot-dm-mine" : ""}"> 2737 <span class="slot-dm-from">${esc(m.from)}</span> 2738 <span class="slot-dm-text">${linkify(esc(m.text || ""))}</span> 2739 </li>`).join("")}</ul>`; 2740 return `${list} 2741 <form class="slot-dm-form" data-action="dm.post"> 2742 <textarea name="text" rows="2" placeholder="dmâ¦"></textarea> 2743 <button type="submit">send</button> 2744 </form>`; 2745} 2746 2747// Build the slotted srcdoc: take the user's customHtml, find every 2748// `data-slot="X"` element, and stamp the rendered slot HTML into its 2749// innerHTML. Done via DOMParser so it's robust against author quoting 2750// quirks. 2751function stampSlots(customHtml, slots) { 2752 let doc; 2753 try { doc = new DOMParser().parseFromString(`<!doctype html><html><body>${customHtml}</body></html>`, "text/html"); } 2754 catch { return customHtml; } 2755 for (const node of doc.querySelectorAll("[data-slot]")) { 2756 const k = node.getAttribute("data-slot"); 2757 if (slots[k] != null) node.innerHTML = slots[k]; 2758 } 2759 return doc.body.innerHTML; 2760} 2761 2762// Editor modal for profile self-edit. Textareas for bio + customHtml + 2763// customCss, inputs for backgroundUrl + audioUrl. Save â PUT /api/profile. 2764// Render the "linked logins" panel inside the profile editor. Fetches 2765// the current user's credentials, displays one row per credential with 2766// type, masked identity, addedAt, and an unlink button (disabled if 2767// it's the only credential â would lock the user out). Add-X buttons 2768// kick off the OAuth link flow with intent=link. 2769async function renderCredentialsInto(host) { 2770 if (!host) return; 2771 host.innerHTML = `<div class="pp2-ed-creds-loading">loadingâ¦</div>`; 2772 let creds = []; 2773 try { 2774 const r = await fetch("/api/account/credentials"); 2775 if (r.ok) creds = (await r.json()).credentials || []; 2776 else host.innerHTML = `<div class="pp2-ed-creds-empty">couldn't load.</div>`; 2777 } catch { 2778 host.innerHTML = `<div class="pp2-ed-creds-empty">couldn't load.</div>`; 2779 return; 2780 } 2781 const isOnly = creds.length <= 1; 2782 const labelFor = (c) => { 2783 if (c.type === "phone") return { name: "phone", id: c.externalId.replace(/^(\+\d{1,3})\d+(\d{4})$/, "$1â¢â¢â¢$2") }; 2784 if (c.type === "google") return { name: "google", id: c.email || c.externalId.slice(0, 12) + "â¦" }; 2785 if (c.type === "apple") return { name: "apple", id: c.email || "(hidden)" }; 2786 if (c.type === "parascene") return { name: "parascene", id: c.externalId.slice(0, 8) + "â¦" }; 2787 return { name: c.type, id: c.externalId }; 2788 }; 2789 const rows = creds.map(c => { 2790 const { name, id } = labelFor(c); 2791 const dt = c.addedAt ? new Date(c.addedAt).toISOString().slice(0, 10) : ""; 2792 const disabled = isOnly ? " disabled title='last credential â add another before removing'" : ""; 2793 return `<div class="pp2-ed-cred" data-type="${esc(c.type)}" data-id="${esc(c.externalId)}"> 2794 <span class="pp2-ed-cred-prov">${esc(name)}</span>
2795 <span class="pp2-ed-cred-id">${esc(id)}</span> 2796 <span class="pp2-ed-cred-date">${esc(dt)}</span> 2797 <button type="button" class="pp2-ed-cred-unlink"${disabled}>unlink</button> 2798 </div>`; 2799 }).join(""); 2800 const hasGoogle = creds.some(c => c.type === "google"); 2801 const hasParascene = creds.some(c => c.type === "parascene"); 2802 const adders = ` 2803 <div class="pp2-ed-cred-adders"> 2804 ${hasGoogle ? `<span class="pp2-ed-cred-have">â google linked</span>` : `<a class="pp2-ed-cred-add" href="/api/auth/google?intent=link">+ link google</a>`} 2805 <span class="pp2-ed-cred-add pp2-ed-cred-disabled" title="apple sign-in not configured yet">+ link apple</span> 2806 ${hasParascene ? `<span class="pp2-ed-cred-have">â parascene linked</span>` : `<button type="button" class="pp2-ed-cred-add" data-action="connect-parascene">+ link parascene (paste key)</button>`} 2807 </div>`; 2808 host.innerHTML = (rows || `<div class="pp2-ed-creds-empty">no credentials? this shouldn't happen.</div>`) + adders; 2809 2810 host.querySelectorAll(".pp2-ed-cred-unlink").forEach(btn => { 2811 btn.addEventListener("click", async (ev) => { 2812 ev.preventDefault(); 2813 const row = btn.closest(".pp2-ed-cred"); 2814 if (!row) return; 2815 const type = row.dataset.type; 2816 const id = row.dataset.id; 2817 if (!confirm(`unlink ${type}? you'll need another way to sign in.`)) return; 2818 btn.disabled = true; btn.textContent = "â¦"; 2819 const r = await fetch(`/api/account/credentials/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, { method: "DELETE" }); 2820 if (!r.ok) { 2821 const j = await r.json().catch(() => ({})); 2822 btn.disabled = false; btn.textContent = "unlink"; 2823 alert("couldn't unlink: " + (j.error || r.status)); 2824 return; 2825 } 2826 renderCredentialsInto(host); // refresh 2827 }); 2828 }); 2829 host.querySelector(`[data-action="connect-parascene"]`)?.addEventListener("click", async () => { 2830 const key = prompt("paste your parascene API key (psn_â¦):"); 2831 if (!key) return; 2832 const r = await fetch("/api/account/parascene-key", { 2833 method: "PUT", headers: { "Content-Type": "application/json" }, 2834 body: JSON.stringify({ key: key.trim() }), 2835 }); 2836 if (!r.ok) { 2837 const j = await r.json().catch(() => ({})); 2838 alert("couldn't save key: " + (j.error || r.status)); 2839 return; 2840 } 2841 // Server-side: also write a parascene credential row so the editor 2842 // shows it. We add a secondary endpoint hook in the parascene-key 2843 // route to write the credential row; for now just refresh the panel 2844 // â if the server hasn't stamped a credential yet, the panel still 2845 // won't show 'parascene linked' until we deploy that companion fix. 2846 await loadAccount(); 2847 renderCredentialsInto(host); 2848 }); 2849} 2850 2851function openProfileEditor(target, current) { 2852 document.querySelector(".pp2-editor")?.remove(); 2853 const ed = document.createElement("div"); 2854 ed.className = "pp2-editor"; 2855 ed.setAttribute("role", "dialog"); 2856 ed.setAttribute("aria-modal", "true"); 2857 ed.innerHTML = ` 2858 <div class="pp2-ed-sheet"> 2859 <button class="pp2-ed-close" type="button" aria-label="close">Ã</button> 2860 <h2 class="pp2-ed-title">edit @${esc(target)}</h2> 2861 <form class="pp2-ed-form"> 2862 <div class="pp2-ed-row pp2-ed-creds-row"> 2863 <span>linked logins</span> 2864 <div class="pp2-ed-creds" aria-live="polite"> 2865 <div class="pp2-ed-creds-loading">loadingâ¦</div> 2866 </div> 2867 </div> 2868 <label class="pp2-ed-row"> 2869 <span>bio (plaintext, visible in the side column)</span> 2870 <textarea name="bio" rows="4" maxlength="2000"></textarea> 2871 </label> 2872 <label class="pp2-ed-row">
2873 <span>background image url (applies to the profile body)</span> 2874 <input type="text" name="backgroundUrl" maxlength="2048"> 2875 </label> 2876 <label class="pp2-ed-row"> 2877 <span>audio url (mp3/midi, plays in profile)</span> 2878 <input type="text" name="audioUrl" maxlength="2048"> 2879 </label> 2880 <label class="pp2-ed-row"> 2881 <span>top friends (comma-separated usernames/slugs, max 12)</span> 2882 <input type="text" name="topFriends" maxlength="600" placeholder="petrichor, kenji, shilabeth"> 2883 </label> 2884 <label class="pp2-ed-row"> 2885 <span>custom fonts (css â @import google fonts or @font-face blocks; loaded before custom css)</span> 2886 <textarea name="customFonts" rows="3" maxlength="10000" class="pp2-ed-mono" placeholder="@import url('https://fonts.googleapis.com/css2?family=VT323&display=swap');"></textarea> 2887 </label> 2888 <label class="pp2-ed-row"> 2889 <span>custom css (injected into your profile's sandbox iframe)</span> 2890 <textarea name="customCss" rows="5" maxlength="20000" class="pp2-ed-mono"></textarea> 2891 </label> 2892 <label class="pp2-ed-row"> 2893 <span>custom html (runs sandboxed â marquee, cursor-followers, stupid js, go wild)</span> 2894 <textarea name="customHtml" rows="10" maxlength="50000" class="pp2-ed-mono"></textarea> 2895 </label> 2896 <div class="pp2-ed-actions"> 2897 <span class="pp2-ed-hint">saved fields survive reload. html/css run in a sandboxed iframe; no access to cookies or parent. <a class="pp2-ed-example" href="#">load example</a> to see what's possible.</span> 2898 <button type="button" class="pp2-btn pp2-ed-cancel">cancel</button> 2899 <button type="submit" class="pp2-btn pp2-ed-save">save</button> 2900 </div> 2901 </form> 2902 </div>`; 2903 const dismiss = () => { ed.remove(); document.removeEventListener("keydown", onKey); }; 2904 const onKey = (e) => { if (e.key === "Escape") dismiss(); }; 2905 document.addEventListener("keydown", onKey); 2906 ed.addEventListener("click", (e) => { if (e.target === ed) dismiss(); }); 2907 ed.querySelector(".pp2-ed-close").addEventListener("click", dismiss); 2908 ed.querySelector(".pp2-ed-cancel").addEventListener("click", dismiss); 2909 2910 // Load-example: drops a starter template into the form fields so 2911 // users can see what a customized profile looks like, tweak it, 2912 // and save. Doesn't save automatically â they still hit save. 2913 ed.querySelector(".pp2-ed-example").addEventListener("click", (ev) => { 2914 ev.preventDefault(); 2915 if (form.customHtml.value && !confirm("replace current content with the example?")) return; 2916 form.customFonts.value = '@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");'; 2917 form.customCss.value = `body { 2918 background: radial-gradient(circle at 30% 20%, #1a1030, #000 70%), #000; 2919 color: #d8d8f0; font-family: "VT323", monospace; font-size: 16px; 2920 padding: 20px; 2921} 2922h1 { color: #ffc857; letter-spacing: 3px; text-transform: uppercase; font-size: 18px; margin-bottom: 4px; } 2923.sub { color: #8888aa; font-size: 12px; } 2924marquee { color: #ff99cc; border-top: 1px dashed #555; border-bottom: 1px dashed #555; padding: 6px 0; margin: 10px 0; } 2925.trail { position: fixed; pointer-events: none; color: #ffc857; font-size: 14px; }`; 2926 form.customHtml.value = `<h1>
2926hello world</h1> 2927<div class="sub">â est. ${new Date().getFullYear()} · the ${(current.username || "your")} zone</div> 2928<marquee behavior="alternate">· this is my corner of the internet · welcome · please leave your shoes ·</marquee> 2929<p>edit this file to make it yours. html, css, and javascript all work. you are inside a sandbox â no external fetch, no cookie access. go weird.</p> 2930 2931<script> 2932// cursor trail â each move drops a dot that fades. 2933document.addEventListener("mousemove", (e) => { 2934 const s = document.createElement("span"); 2935 s.className = "trail"; 2936 s.textContent = "·"; 2937 s.style.left = (e.clientX - 4) + "px"; 2938 s.style.top = (e.clientY - 4) + "px"; 2939 document.body.appendChild(s); 2940 setTimeout(() => s.remove(), 600); 2941}); 2942</script>`; 2943 form.audioUrl.focus(); 2944 }); 2945 2946 const form = ed.querySelector(".pp2-ed-form"); 2947 2948 // Linked-logins block â fetched lazily so opening the editor isn't 2949 // blocked on /api/account/credentials. 2950 renderCredentialsInto(ed.querySelector(".pp2-ed-creds")); 2951 2952 form.bio.value = current.bio || ""; 2953 form.backgroundUrl.value = current.backgroundUrl || ""; 2954 form.audioUrl.value = current.audioUrl || ""; 2955 form.customFonts.value = current.customFonts || ""; 2956 form.customCss.value = current.customCss || ""; 2957 form.customHtml.value = current.customHtml || ""; 2958 // topFriends comes as an array of resolved {name,kind,avatarUrl}. 2959 // Editor takes a comma-separated plain list â we parse back on save. 2960 form.topFriends.value = (current.topFriends || []).map(f => f.name || f).join(", "); 2961 2962 form.addEventListener("submit", async (ev) => { 2963 ev.preventDefault(); 2964 const payload = { 2965 bio: form.bio.value, 2966 backgroundUrl: form.backgroundUrl.value, 2967 audioUrl: form.audioUrl.value, 2968 customFonts: form.customFonts.value, 2969 customCss: form.customCss.value, 2970 customHtml: form.customHtml.value, 2971 topFriends: form.topFriends.value 2972 .split(/[,\s]+/) 2973 .map(s => s.replace(/^@/, "").toLowerCase()) 2974 .filter(Boolean), 2975 }; 2976 const saveBtn = form.querySelector(".pp2-ed-save"); 2977 saveBtn.disabled = true; saveBtn.textContent = "savingâ¦"; 2978 try { 2979 const r = await fetch(`/api/profile/${encodeURIComponent(target)}`, { 2980 method: "PUT", 2981 headers: { "Content-Type": "application/json" }, 2982 body: JSON.stringify(payload), 2983 }); 2984 if (!r.ok) { 2985 const j = await r.json().catch(() => ({})); 2986 saveBtn.textContent = "save failed: " + (j.error || "error"); 2987 saveBtn.disabled = false; 2988 return; 2989 } 2990 dismiss(); 2991 // Re-render the profile panel with the fresh data. 2992 document.querySelector(".profile-panel")?.remove(); 2993 openProfilePanel(target); 2994 } catch (e) { 2995 saveBtn.textContent = "save error"; 2996 saveBtn.disabled = false; 2997 } 2998 }); 2999 3000 document.body.appendChild(ed); 3001} 3002 3003// Conjure flow: analyze the caller's most recent /gen image via Claude 3004// vision, then show a picker modal with the image + overlay buttons at each 3005// character's bbox. User clicks a character â we POST create to forge a 3006// persona for that specific subject. 3007async function openConjurePicker() { 3008 const placeholder = say("analyzing imageâ¦", "sys"); 3009 const r = await fetch("/api/conjure/analyze", { method: "POST" }); 3010 const j = await r.json().catch(() => ({})); 3011 placeholder.remove(); 3012 if (!r.ok) { 3013 if (j.error === "budget_exhausted") { say("out of daily claude budget.", "err"); return; } 3014 say("conjure analyze failed: " + (j.error || "unknown") + (j.message ? ": " + j.message : ""), "err"); 3015 return; 3016 } 3017 if (!j.characters?.length) { 3018 say("no characters detected in that image â try /gen something with a subject.", "err"); 3019 return; 3020 } 3021 showCharacterPicker(j); 3022} 3023 3024function showCharacterPicker({ image_url, characters, scene_summary, cost, cached }) {
3025 const overlay = document.createElement("div"); 3026 overlay.className = "conjure-picker"; 3027 const charButtons = characters.map((c, i) => { 3028 const cx = (c.bbox.x + c.bbox.w / 2) * 100; 3029 const cy = (c.bbox.y + c.bbox.h / 2) * 100; 3030 return `<button type="button" class="cp-char" data-idx="${i}" style="left:${cx.toFixed(1)}%;top:${cy.toFixed(1)}%;" title="character ${i + 1}">${i + 1}</button>`; 3031 }).join(""); 3032 const charCards = characters.map((c, i) => ` 3033 <div class="cp-card" data-idx="${i}"> 3034 <div class="cp-card-head">${i + 1}. ${esc(c.name_hint || c.position?.label || "character " + (i + 1))}</div> 3035 <div class="cp-card-desc">${esc(c.generic_description || c.appearance || "")}</div> 3036 <button type="button" class="btn cp-pick" data-idx="${i}">conjure this one</button> 3037 </div>`).join(""); 3038 overlay.innerHTML = ` 3039 <div class="cp-panel" role="dialog" aria-modal="true"> 3040 <button type="button" class="cp-close" aria-label="close">Ã</button> 3041 <div class="cp-title">pick a character to conjure</div> 3042 <div class="cp-sub">${esc(scene_summary || "")} ${cached ? "· (cached)" : cost ? `· ${esc(cost)} for analysis` : ""}</div> 3043 <div class="cp-img-wrap"> 3044 <img src="${esc(image_url)}" alt="" class="cp-img"> 3045 ${charButtons} 3046 </div> 3047 <div class="cp-list">${charCards}</div> 3048 </div>`; 3049 const dismiss = () => { overlay.remove(); document.removeEventListener("keydown", onKey); }; 3050 const onKey = (e) => { if (e.key === "Escape") dismiss(); }; 3051 document.addEventListener("keydown", onKey); 3052 overlay.addEventListener("click", (e) => { 3053 if (e.target === overlay || e.target.classList.contains("cp-close")) { dismiss(); return; } 3054 const btn = e.target.closest(".cp-pick, .cp-char"); 3055 if (!btn) return; 3056 const idx = parseInt(btn.dataset.idx, 10); 3057 confirmConjure(idx, overlay, dismiss); 3058 }); 3059 document.body.appendChild(overlay); 3060} 3061 3062async function confirmConjure(characterIndex, overlay, dismiss) { 3063 overlay.querySelectorAll("button").forEach(b => b.disabled = true); 3064 const placeholder = say(`conjuring character ${characterIndex + 1}â¦`, "sys"); 3065 try { 3066 const r = await fetch("/api/conjure/create", { 3067 method: "POST", 3068 headers: { "Content-Type": "application/json" }, 3069 body: JSON.stringify({ character_index: characterIndex }), 3070 }); 3071 const j = await r.json().catch(() => ({})); 3072 placeholder.remove(); 3073 if (!r.ok) { 3074 const msg = j.hint || j.error || j.message || "unknown"; 3075 say("conjure failed: " + msg, "err"); 3076 overlay.querySelectorAll("button").forEach(b => b.disabled = false); 3077 return; 3078 } 3079 dismiss(); 3080 } catch (e) { 3081 placeholder.remove(); 3082 say("conjure error: " + e.message, "err"); 3083 overlay.querySelectorAll("button").forEach(b => b.disabled = false); 3084 } 3085} 3086 3087function say(text, cls = "sys") { 3088 const d = document.createElement("div"); d.className = "line " + cls; d.textContent = text; 3089 logEl.appendChild(d); 3090 return d; 3091} 3092 3093// /claude and /kill are server-handled (WS chat interception picks them up 3094// when the message is broadcast). /conjure is client-driven: the chat 3095// message broadcasts so the room sees the attempt, then the client fires 3096// the analyze â pick â create flow via HTTP. 3097const SERVER_SLASHES = new Set(["claude", "conjure", "kill", "see"]); 3098 3099function broadcastChat(text) { 3100 const out = { t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), text }; 3101 renderChat(out); 3102 if (!ws) connectSignaling(); 3103 const trySend = () => { 3104 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out)); 3105 else setTimeout(trySend, 100); 3106 }; 3107 trySend(); 3108} 3109 3110async function handleSlash(raw) { 3111 const [cmd, ...rest] = raw.slice(1).split(/\s+/); 3112 const arg = rest.join(" ").trim(); 3113 if (SERVER_SLASHES.has(cmd)) { 3114 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3115 broadcastChat(raw); 3116 if (cmd === "conjure") { 3117 openConjurePicker().catch(e => say("conjure error: " + e.message, "err")); 3118 } 3119 return; 3120 } 3121 if (cmd === "key") { 3122 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3123 if (!arg) return say("/key psn_⦠(paste your parascene api key; empty to clear)", "sys"); 3124 const r = await fetch("/api/account/parascene-key", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: arg === "clear" ? "" : arg }) }); 3125 const j = await r.json(); 3126 if (!r.ok) return say("key error: " + (j.error || "unknown"), "err"); 3127 say(arg === "clear" ? "parascene key cleared" : "parascene key saved", "sys"); 3128 return; 3129 } 3130 if (cmd === "gen") { 3131 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3132 if (!arg) return say("/gen <prompt>", "sys"); 3133 const genPlaceholder = say(`generating: ${arg}â¦`, "sys"); 3134 const r = await fetch("/api/gen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: arg }) }); 3135 const j = await r.json(); 3136 genPlaceholder.remove(); 3137 if (!r.ok) return say("gen failed: " + (j.error || "unknown") + (j.hint ? " â " + j.hint : ""), "err"); 3138 const out = { t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), text: `/gen ${arg}`, attach: j.image_url, gen_id: j.id, published: !!j.published }; 3139 renderChat(out); 3140 ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out)); 3141 return; 3142 } 3143 if (cmd === "mut") { 3144 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3145 if (!arg) return say("/mut [<id>] <mutation description> â modifies a creation (defaults to your most recent /gen)", "sys"); 3146 // Optional leading digit token = explicit source gen_id. 3147 let sourceRef = null; 3148 let mutation = arg; 3149 const m = arg.match(/^(\d+)\s+(.+)$/); 3150 if (m) { sourceRef = m[1]; mutation = m[2]; } 3151 const placeholder = say(`mutating${sourceRef ? ` ${sourceRef}` : ""}: ${mutation}â¦`, "sys"); 3152 const r = await fetch("/api/mut", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mutation, source_ref: sourceRef }) }); 3153 const j = await r.json(); 3154 placeholder.remove(); 3155 if (!r.ok) return say("mut failed: " + (j.error || "unknown"), "err"); 3156 const out = { 3157 t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), 3158 text: `/mut ${arg}`, attach: j.image_url, gen_id: j.id, 3159 }; 3160 renderChat(out); 3161 ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out)); 3162 return; 3163 } 3164 if (cmd === "pfp") { 3165 // /pfp <username|botslug> <gen_id> â set someone's avatar. 3166 // Self-targeting is always allowed; setting other accounts is 3167 // gated by server-side allowlist (dadchords, oceanman by default). 3168 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3169 const m = arg && arg.match(/^@?([a-z][a-z0-9_]*)\s+(\d+)\s*$/i); 3170 if (!m) return say("/pfp <username|botslug> <gen_id> e.g. /pfp xyla 7353", "sys"); 3171 const target = m[1]; 3172 const genId = m[2]; 3173 const r = await fetch("/api/pfp", { 3174 method: "POST", headers: { "Content-Type": "application/json" }, 3175 body: JSON.stringify({ target, gen_id: genId }), 3176 }); 3177 const j = await r.json(); 3178 if (!r.ok) return say("pfp failed: " + (j.error || "unknown"), "err"); 3179 say(`pfp updated: ${j.kind === "bot" ? "@" : "@"}${j.target} â ${j.gen_id}`, "sys"); 3180 // Refresh local account state so own-avatar updates immediately 3181 // in the sidebar etc. 3182 await loadAccount(); 3183 return; 3184 } 3185 if (cmd === "vid") { 3186 // /vid <id> <prompt>
3186 â image-to-video via parascene's 3187 // wan-video/wan-2.2-i2v-fast (~35-45s typical). 3188 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3189 if (!arg) return say("/vid <id> <prompt> â animate a creation. e.g. /vid 7353 dancing in the rain", "sys"); 3190 const m = arg.match(/^(\d+)\s+(.+)$/); 3191 if (!m) return say("/vid needs a numeric id then a prompt â '/vid 7353 dancing'", "err"); 3192 const sourceId = m[1]; 3193 const prompt = m[2].trim(); 3194 const placeholder = say(`animating ${sourceId}: ${prompt}â¦`, "sys"); 3195 const r = await fetch("/api/vid", { 3196 method: "POST", headers: { "Content-Type": "application/json" }, 3197 body: JSON.stringify({ source_id: sourceId, prompt }), 3198 }); 3199 const j = await r.json(); 3200 placeholder.remove(); 3201 if (!r.ok) return say("vid failed: " + (j.error || "unknown"), "err"); 3202 const out = { 3203 t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), 3204 text: `/vid ${sourceId} ${prompt}`, 3205 attach_video: j.video_url, gen_id: j.id, source_id: j.source_id, 3206 }; 3207 renderChat(out); 3208 ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out)); 3209 return; 3210 } 3211 if (cmd === "fuse" || cmd === "combine") { 3212 // /fuse 1234 5678 the figures shaking hands 3213 // /fuse 1234 5678 9012 epic group portrait 3214 // First N tokens that are pure numbers are the source IDs (2-4), 3215 // the rest is the prompt. 3216 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3217 if (!arg) return say(`/${cmd} <id1> <id2> [<id3> â¦] <prompt> â combine 2â4 creations into one new image`, "sys"); 3218 const tokens = arg.split(/\s+/); 3219 const ids = []; 3220 let i = 0; 3221 while (i < tokens.length && /^\d+$/.test(tokens[i])) { ids.push(tokens[i]); i++; } 3222 const prompt = tokens.slice(i).join(" ").trim(); 3223 if (ids.length < 2) return say(`/${cmd} needs at least 2 numeric ids before the prompt`, "err"); 3224 if (!prompt) return say(`/${cmd} needs a prompt after the ids â describe what the combined image should be`, "err"); 3225 const placeholder = say(`fusing ${ids.join(" + ")}: ${prompt}â¦`, "sys"); 3226 const r = await fetch("/api/fuse", { 3227 method: "POST", headers: { "Content-Type": "application/json" }, 3228 body: JSON.stringify({ ids, prompt }), 3229 }); 3230 const j = await r.json(); 3231 placeholder.remove(); 3232 if (!r.ok) return say("fuse failed: " + (j.error || "unknown") + (j.missing ? ` (missing id ${j.missing})` : ""), "err"); 3233 const out = { 3234 t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), 3235 text: `/fuse ${ids.join(" ")} ${prompt}`, attach: j.image_url, gen_id: j.id, 3236 }; 3237 renderChat(out); 3238 ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out)); 3239 return; 3240 } 3241 say("unknown command: /" + cmd, "err"); 3242} 3243 3244// Autogrow the textarea up to its CSS max-height. Reset to auto first so 3245// scrollHeight reflects the natural height at current content (not the prior 3246// taller one). min-height in CSS keeps empty state at exactly one line. 3247function resizeComposer() { 3248 msgInput.style.height = "auto"; 3249 msgInput.style.height = msgInput.scrollHeight + "px"; 3250} 3251msgInput.addEventListener("input", resizeComposer); 3252 3253// Slash-command popup: as soon as the textarea starts with "/", show the 3254// matching commands and arg hints. Once the user has typed a full command 3255// plus a space, collapse to just that one entry so the hint stays in view. 3256const SLASH_CMDS = [ 3257 { cmd: "/gen", hint: "<image prompt>", desc: "generate image" }, 3258 { cmd: "/mut", hint: "<mutation>", desc: "mutate your last image" }, 3259 { cmd: "/see", hint: "<parascene id>", desc: "import a parascene creation" }, 3260 { cmd: "/kill", hint: "<botname>", desc: "end a bot's life" }, 3261 { cmd: "/key", hint: "<parascene key>", desc: "set parascene api key" }, 3262];
3263// /conjure exists but is intentionally NOT advertised. Bots can issue it 3264// via the operator; humans can still click the picker modal from a /gen 3265// image (app.js openConjurePicker) â planned for the next batch as a 3266// per-image hover affordance instead of a slash command. 3267function renderSlashItems(items) { 3268 slashPopup.innerHTML = items.map(c => 3269 `<div class="slash-item" data-cmd="${c.cmd}"><span class="slash-cmd">${c.cmd}</span>` + 3270 (c.hint ? ` <span class="slash-hint">${esc(c.hint)}</span>` : "") + 3271 ` <span class="slash-desc">${c.desc}</span></div>` 3272 ).join(""); 3273} 3274function updateSlashPopup() { 3275 const v = msgInput.value; 3276 if (!v.startsWith("/")) { slashPopup.hidden = true; return; } 3277 const sp = v.indexOf(" "); 3278 if (sp === -1) { 3279 const prefix = v.toLowerCase(); 3280 const matches = SLASH_CMDS.filter(c => c.cmd.startsWith(prefix)); 3281 if (!matches.length) { slashPopup.hidden = true; return; } 3282 renderSlashItems(matches); 3283 } else { 3284 const cmd = v.slice(0, sp).toLowerCase(); 3285 const m = SLASH_CMDS.find(c => c.cmd === cmd); 3286 if (!m) { slashPopup.hidden = true; return; } 3287 renderSlashItems([m]); 3288 } 3289 slashPopup.hidden = false; 3290} 3291msgInput.addEventListener("input", updateSlashPopup); 3292msgInput.addEventListener("focus", updateSlashPopup); 3293// Click-to-insert: completes the command and leaves the cursor where the arg 3294// would start (end of input + trailing space). mousedown fires before blur so 3295// the click registers before the popup hides. 3296slashPopup.addEventListener("mousedown", (e) => { 3297 const item = e.target.closest(".slash-item"); 3298 if (!item) return; 3299 e.preventDefault(); 3300 const cmd = item.dataset.cmd; 3301 const needsArg = SLASH_CMDS.find(c => c.cmd === cmd)?.hint; 3302 msgInput.value = needsArg ? cmd + " " : cmd; 3303 msgInput.focus(); 3304 updateSlashPopup(); 3305 resizeComposer(); 3306}); 3307 3308// Enter sends (matches the iOS "send" enterkeyhint); Shift+Enter inserts a 3309// newline. IME compositions (Japanese/Chinese input) are passed through â 3310// isComposing + keyCode 229 indicate a composition event, not a submit. 3311msgInput.addEventListener("keydown", (e) => { 3312 if (e.key !== "Enter") return; 3313 if (e.shiftKey) return; 3314 if (e.isComposing || e.keyCode === 229) return; 3315 e.preventDefault(); 3316 submitComposer(); 3317}); 3318 3319// âââââââââ mic: record + upload voice message âââââââââ 3320// Click to start, click to stop. Max ~30s clip (server caps payload at 2MB 3321// so 32k Opus is comfortable up to ~2min, but we hard-stop at 30s to keep 3322// the feel conversational). MediaRecorder handles Opus-in-WebM for us. 3323let mediaRec = null; 3324let micChunks = []; 3325let micCap = null; 3326 3327// When the user records a voice note, incoming bot audio within this 3328// window auto-plays â lets a back-and-forth voice exchange feel fluid 3329// without tapping to play each reply. Reset on every user mic send. 3330let lastUserMicTs = 0; 3331const AUTO_PLAY_WINDOW_MS = 20_000; 3332 3333micBtn.addEventListener("click", async () => { 3334 if (mediaRec && mediaRec.state === "recording") { 3335 mediaRec.stop(); 3336 return; 3337 } 3338 try { 3339 const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); 3340 mediaRec = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 32000 }); 3341 micChunks = []; 3342 mediaRec.addEventListener("dataavailable", (e) => { if (e.data.size) micChunks.push(e.data); }); 3343 mediaRec.addEventListener("stop", async () => { 3344 micBtn.classList.remove("recording"); 3345 clearTimeout(micCap); micCap = null; 3346 // Release the mic so the browser's indicator turns off. 3347 stream.getTracks().forEach(t => t.stop()); 3348 if (!micChunks.length) return; 3349 const blob = new Blob(micChunks, { type: "audio/webm" }); 3350 if (blob.size < 1000) return; // accidental tap 3351 await uploadVoiceMessage(blob); 3352 }); 3353 mediaRec.start(); 3354 micBtn.classList.add("recording"); 3355 micCap = setTimeout(() => { try { mediaRec.stop(); } catch {} }, 30_000); 3356 } catch (e) { 3357 say("mic error: " + e.message, "err"); 3358 } 3359}); 3360 3361async function uploadVoiceMessage(blob) { 3362 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3363 const placeholder = say("sending voiceâ¦", "sys"); 3364 try { 3365 const data_base64 = await new Promise((resolve, reject) => { 3366 const r = new FileReader(); 3367 r.onload = () => resolve(String(r.result).split(",")[1]); 3368 r.onerror = reject;
3369 r.readAsDataURL(blob); 3370 }); 3371 const resp = await fetch("/api/upload/audio", { 3372 method: "POST", 3373 headers: { "Content-Type": "application/json" }, 3374 body: JSON.stringify({ data_base64, mime: blob.type }), 3375 }); 3376 const j = await resp.json().catch(() => ({})); 3377 placeholder.remove(); 3378 if (!resp.ok) { say("upload failed: " + (j.error || "unknown"), "err"); return; } 3379 // Server returned STT transcript. Ship it on the chat msg so the 3380 // operator/tank/kamaji dispatchers see spoken words as addressable 3381 // text, and the frontend can reveal it as a caption. 3382 const out = { 3383 t: "chat", id: crypto.randomUUID(), 3384 from: account.username, ts: Date.now(), 3385 text: "", audio_url: j.audio_url, 3386 ...(j.transcript ? { transcript: j.transcript } : {}), 3387 }; 3388 // Stamp the "I just recorded audio" window so any bot reply within 3389 // AUTO_PLAY_WINDOW_MS auto-plays (see renderChat below). 3390 lastUserMicTs = Date.now(); 3391 renderChat(out); 3392 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out)); 3393 } catch (e) { 3394 placeholder.remove(); 3395 say("voice send failed: " + e.message, "err"); 3396 } 3397} 3398 3399// âââââââââ pic button âââââââââ 3400// Click â opens the file picker with capture="environment" so mobile 3401// browsers show the camera option front-and-center. Desktop users get 3402// a standard file picker. On selection we base64-encode the file and 3403// POST to /api/pic which uploads to parascene via the uploadImage 3404// method (a method we discovered by probing the Parascene create 3405// endpoint â data:image/...;base64,... URIs work as image_url). The 3406// response gives us a real parascene creation id, so the chat post 3407// carries a gen_id and the photo can be /mut'd, conjured from, etc. 3408picBtn?.addEventListener("click", () => { 3409 if (!account) { say("sign in first", "err"); openVerifyModal(); return; } 3410 picInput.value = ""; 3411 picInput.click(); 3412}); 3413picInput?.addEventListener("change", async () => { 3414 const file = picInput.files?.[0]; 3415 if (!file) return; 3416 const MAX_BYTES = 8_000_000; 3417 if (file.size > MAX_BYTES) { say(`image too large (${(file.size/1024/1024).toFixed(1)}MB, max 8MB)`, "err"); return; } 3418 const placeholder = say("uploading photoâ¦", "sys"); 3419 picBtn.disabled = true; 3420 try { 3421 const data_base64 = await new Promise((resolve, reject) => { 3422 const r = new FileReader(); 3423 r.onload = () => resolve(String(r.result).split(",")[1] || ""); 3424 r.onerror = reject; 3425 r.readAsDataURL(file); 3426 }); 3427 const resp = await fetch("/api/pic", { 3428 method: "POST", 3429 headers: { "Content-Type": "application/json" }, 3430 body: JSON.stringify({ data_base64, mime: file.type || "image/jpeg" }), 3431 }); 3432 const j = await resp.json().catch(() => ({})); 3433 placeholder.remove(); 3434 if (!resp.ok) { say("upload failed: " + (j.error || "unknown") + (j.hint ? " â " + j.hint : ""), "err"); return; } 3435 const out = { 3436 t: "chat", id: crypto.randomUUID(), 3437 from: account.username, ts: Date.now(), 3438 text: "", 3439 attach: j.image_url, 3440 gen_id: j.id, 3441 }; 3442 renderChat(out); 3443 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out)); 3444 } catch (e) { 3445 placeholder.remove(); 3446 say("photo send failed: " + e.message, "err"); 3447 } finally { 3448 picBtn.disabled = false; 3449 } 3450}); 3451 3452// âââââââââ parascene live-feed grid (infinite scroll) âââââââââ 3453// 3454// 5-col grid of recent public-feed creations rendered INSIDE the chat 3455// log scroll container, after all messages. As the user scrolls down 3456// past the latest chat into the grid, IntersectionObserver triggers 3457// fetching of the next page well in advance (~8 rows). Each cell is a 3458// fixed aspect-ratio (1:1) box so layout never reflows when images 3459// arrive â the grid's full final height is known the moment cells 3460// are appended. Browser <img loading="lazy"> handles the actual byte 3461// fetches so 1000+ rows in DOM cost nothing until they're visible. 3462// 3463// Sizing budget:
3464// FEED_INITIAL_PAGE = 25 items rendered immediately at boot 3465// FEED_PAGE_SIZE = 50 items per subsequent page 3466// FEED_TOTAL_CAP = 1600 items hard cap 3467// On each page-render, immediately kick off the NEXT page (if not at 3468// cap) so there's always a buffered page in flight ahead of the 3469// user's scroll â they should never see an empty bottom edge. 3470// 3471// WS deltas (newest items posted on parascene in the last 30s) prepend 3472// to the grid. Cells whose id appears as a gen_id on any chat message 3473// dim to 0.6 opacity and lose hover actions. 3474const seenGenIds = new Set(); 3475const feedGridItems = new Map(); // id â cell DOM 3476let feedOldestId = null; // for /api/parascene/feed?before=<id> 3477let feedLoadingMore = false; 3478let feedExhausted = false; 3479const FEED_INITIAL_PAGE = 32; 3480const FEED_PAGE_SIZE = 32; 3481const FEED_TOTAL_CAP = 256; 3482// Cells per sprite map (server endpoint /u/feed-map accepts up to 32). 3483const FEED_SPRITE_BATCH = 32; 3484 3485let feedGridEl = null; 3486let feedSentinel = null; 3487 3488function ensureFeedGrid() { 3489 if (feedGridEl) return feedGridEl; 3490 if (!logEl) return null; 3491 if (!FEED_ENABLED) return null; 3492 // A 1px-tall marker at the chat-feed boundary. Acts as a scroll-snap 3493 // anchor â slow scrolls catch on it and bounce back to chat-bottom; 3494 // momentum/intentional scrolls pass through into the grid. The 3495 // perceived effect is a 'speed bump' at the end of chat. 3496 const boundary = document.createElement("div"); 3497 boundary.className = "chat-feed-boundary"; 3498 logEl.appendChild(boundary); 3499 const grid = document.createElement("div"); 3500 grid.className = "feed-grid"; 3501 grid.id = "feedGrid"; 3502 logEl.appendChild(grid); 3503 feedSentinel = document.createElement("div"); 3504 feedSentinel.className = "feed-grid-sentinel"; 3505 logEl.appendChild(feedSentinel); 3506 feedGridEl = grid; 3507 // Observe the sentinel inside .log. rootMargin extends the observer 3508 // 1200px below the visible viewport so we begin the next-page 3509 // fetch well before the user gets close to the bottom. 3510 new IntersectionObserver((entries) => { 3511 for (const e of entries) { 3512 console.log(`[feed] observer: intersecting=${e.isIntersecting} ratio=${e.intersectionRatio.toFixed(2)} sentinel-top=${e.boundingClientRect.top.toFixed(0)} root-bottom=${(e.rootBounds?.bottom ?? "?")}`); 3513 if (e.isIntersecting) loadFeedPage(); 3514 } 3515 }, { root: logEl, rootMargin: "0px 0px 1200px 0px" }).observe(feedSentinel); 3516 console.log("[feed] grid mounted. logEl scrollHeight=", logEl.scrollHeight, "clientHeight=", logEl.clientHeight); 3517 return grid; 3518} 3519 3520function feedCellHtml(it) { 3521 const id = String(it.id); 3522 // No <img>. The cell's tile is painted via background-image 3523 // (sprite map) + background-position assigned by refreshFeedSprite 3524 // based on the cell's DOM index. One sprite fetch = whole grid. 3525 const psUrl = `https://www.parascene.com/creations/${encodeURIComponent(id)}`; 3526 return `<div class="feed-cell" data-id="${esc(id)}"> 3527 <a class="feed-cell-link" href="${esc(psUrl)}" target="_blank" rel="noopener" title="open on parascene">â</a> 3528 <div class="feed-cell-id">${esc(id)}</div> 3529 <div class="feed-cell-actions"> 3530 <button type="button" class="feed-cell-btn" data-action="see" title="add to chat">+chat</button> 3531 <button type="button" class="feed-cell-btn" data-action="mut" title="mutate">+mut</button> 3532 </div> 3533 </div>`; 3534} 3535 3536// Walk filled cells in DOM order, group into batches of 32, and point 3537// each cell at its batch's /u/feed-map sprite (one image per 32-tuple). 3538// bg-position is by the cell's index within its batch. With 256 cap â 3539// up to 8 sprites; the browser fetches each batch once and reuses it. 3540function refreshFeedSprite() { 3541 if (!feedGridEl) return; 3542 const cells = feedGridEl.querySelectorAll(".feed-cell:not(.feed-cell-empty)"); 3543 if (!cells.length) return; 3544 const ids = []; 3545 cells.forEach((cell) => { if (cell.dataset.id) ids.push(cell.dataset.id); }); 3546 cells.forEach((cell, i) => { 3547 const batch = Math.floor(i / FEED_SPRITE_BATCH); 3548 const inBatch = i % FEED_SPRITE_BATCH; 3549 const col = inBatch % 4;
3550 const row = Math.floor(inBatch / 4); 3551 cell.style.backgroundPosition = `${-col * 100}px ${-row * 100}px`; 3552 const batchIds = ids.slice(batch * FEED_SPRITE_BATCH, (batch + 1) * FEED_SPRITE_BATCH); 3553 if (batchIds.length) { 3554 cell.style.backgroundImage = `url(/u/feed-map/${batchIds.join(",")}.webp)`; 3555 } 3556 }); 3557} 3558 3559// Scroll-gated image-load queue. Loads are completely paused while 3560// the user is scrolling â any in-flight load is aborted (src cleared 3561// + img re-queued) the instant a scroll event fires. After 300ms of 3562// no scroll events, the pump resumes and ticks one image every 3563// 200ms. Net effect: zero decode pressure on the scroll thread, and 3564// images fill in only when the user is motionless. 3565const feedImgQueue = []; 3566const FEED_LOAD_INTERVAL_MS = 200; 3567const FEED_SCROLL_IDLE_MS = 300; 3568let feedLoadTimer = null; 3569let feedIdleTimer = null; 3570let feedIsScrolling = false; 3571let feedLoadingImg = null; 3572function startFeedLoadPump() { 3573 if (feedLoadTimer) return; 3574 if (feedIsScrolling) return; 3575 feedLoadTimer = setInterval(() => { 3576 if (feedIsScrolling) return; 3577 while (feedImgQueue.length) { 3578 const img = feedImgQueue.shift(); 3579 if (!img.isConnected || !img.dataset.src) continue; 3580 const src = img.dataset.src; 3581 img.removeAttribute("data-src"); 3582 feedLoadingImg = img; 3583 const clear = () => { if (feedLoadingImg === img) feedLoadingImg = null; }; 3584 img.addEventListener("load", clear, { once: true }); 3585 img.addEventListener("error", clear, { once: true }); 3586 img.src = src; 3587 return; 3588 } 3589 clearInterval(feedLoadTimer); 3590 feedLoadTimer = null; 3591 }, FEED_LOAD_INTERVAL_MS); 3592} 3593function queueFeedImg(img) { 3594 if (!img || !img.dataset.src) return; 3595 feedImgQueue.push(img); 3596 startFeedLoadPump(); 3597} 3598function abortFeedLoad() { 3599 const img = feedLoadingImg; 3600 if (!img) return; 3601 feedLoadingImg = null; 3602 if (!img.isConnected) return; 3603 if (img.complete && img.naturalWidth > 0) return; // already decoded 3604 const src = img.currentSrc || img.src; 3605 if (!src) return; 3606 img.removeAttribute("src"); 3607 img.dataset.src = src; 3608 feedImgQueue.unshift(img); 3609} 3610function onFeedScrollStart() { 3611 if (!feedIsScrolling) { 3612 feedIsScrolling = true; 3613 if (feedLoadTimer) { clearInterval(feedLoadTimer); feedLoadTimer = null; } 3614 abortFeedLoad(); 3615 } 3616 if (feedIdleTimer) clearTimeout(feedIdleTimer); 3617 feedIdleTimer = setTimeout(() => { 3618 feedIsScrolling = false; 3619 feedIdleTimer = null; 3620 if (feedImgQueue.length) startFeedLoadPump(); 3621 }, FEED_SCROLL_IDLE_MS); 3622} 3623logEl.addEventListener("scroll", onFeedScrollStart, { passive: true }); 3624 3625function refreshFeedGridDimming() { 3626 for (const [id, cell] of feedGridItems) { 3627 if (seenGenIds.has(id)) cell.classList.add("feed-cell-dim"); 3628 else cell.classList.remove("feed-cell-dim"); 3629 } 3630} 3631const refreshFeedRailDimming = refreshFeedGridDimming; // back-compat alias 3632 3633// Fill items into pre-reserved empty slots in row-by-row chunks. Each 3634// row of 5 cells reveals on the next animation frame so iOS doesn't 3635// have to paint the whole batch in one go. Once empty slots run out, 3636// fall back to plain append (the cap-200 cushion is sized for the 3637// initial pages; pagination beyond appends new rows). 3638function appendFeedItems(items, { prepend = false } = {}) { 3639 const grid = ensureFeedGrid(); 3640 if (!grid || !items?.length) return; 3641 const fillRow = (rowItems) => { 3642 for (const it of rowItems) { 3643 const id = String(it.id); 3644 if (feedGridItems.has(id)) continue; 3645 // Find an empty reserved slot to fill in place. 3646 let empty = grid.querySelector(".feed-cell-empty"); 3647 const tmp = document.createElement("div"); 3648 tmp.innerHTML = feedCellHtml(it); 3649 const cell = tmp.firstElementChild; 3650 if (seenGenIds.has(id)) cell.classList.add("feed-cell-dim"); 3651 if (empty && !prepend) { 3652 empty.replaceWith(cell); 3653 } else if (prepend && grid.firstChild) { 3654 grid.insertBefore(cell, grid.firstChild); 3655 } else { 3656 grid.appendChild(cell); 3657 } 3658 feedGridItems.set(id, cell); 3659 const idN = Number(it.id); 3660 if (!feedOldestId || idN < feedOldestId) feedOldestId = idN; 3661 } 3662 }; 3663 // With the sprite, all 32 cells share one image fetch, so we don't 3664 // need to drip rows in. Render in a single pass and let the browser 3665 // decode the one webp. 3666 fillRow(items); 3667 refreshFeedSprite(); 3668} 3669 3670function applyFeedDelta(added) { 3671 if (!Array.isArray(added) || !added.length) return; 3672 // Newest first â DESC sort, prepend. 3673 const newest = added.slice().sort((a, b) => (b.ts || 0) - (a.ts || 0)); 3674 appendFeedItems(newest, { prepend: true }); 3675 // Trim oldest cells so the grid stays at FEED_TOTAL_CAP latest. 3676 if (!feedGridEl) return; 3677 while (feedGridItems.size > FEED_TOTAL_CAP) { 3678 const last = feedGridEl.lastElementChild; 3679 if (!last) break; 3680 const lastId = last.dataset?.id; 3681 last.remove(); 3682 if (lastId) feedGridItems.delete(lastId); 3683 } 3684 // Re-point the sprite URL since the 32-tuple changed. 3685 refreshFeedSprite(); 3686} 3687 3688async function loadFeedPage() { 3689 if (feedLoadingMore) { console.log("[feed] loadFeedPage skipped: in flight"); return; } 3690 if (feedExhausted) { console.log("[feed] loadFeedPage skipped: exhausted"); return; } 3691 if (feedGridItems.size >= FEED_TOTAL_CAP) { console.log("[feed] cap reached"); feedExhausted = true; return; } 3692 feedLoadingMore = true; 3693 try { 3694 const url = feedOldestId 3695 ? `/api/parascene/feed?before=${feedOldestId}&limit=${FEED_PAGE_SIZE}` 3696 : `/api/parascene/feed?limit=${FEED_INITIAL_PAGE}`; 3697 console.log("[feed] fetching", url, "current items:", feedGridItems.size, "olde
3697stId:", feedOldestId); 3698 const r = await fetch(url); 3699 if (!r.ok) { console.warn("[feed] fetch !ok", r.status); return; } 3700 const j = await r.json(); 3701 const items = j.items || []; 3702 console.log(`[feed] got ${items.length} items`, items.slice(0, 3).map(i => i.id), "â¦"); 3703 if (!items.length) { 3704 console.log("[feed] zero items â marking exhausted"); 3705 feedExhausted = true; 3706 } else { 3707 const before = feedGridItems.size; 3708 appendFeedItems(items, { prepend: false }); 3709 console.log(`[feed] appended ${feedGridItems.size - before} new (size: ${before} â ${feedGridItems.size}); oldestId now:`, feedOldestId); 3710 } 3711 if (feedGridItems.size >= FEED_TOTAL_CAP) feedExhausted = true; 3712 } catch (e) { 3713 console.warn("[feed-page]", e.message); 3714 } finally { 3715 feedLoadingMore = false; 3716 } 3717} 3718 3719// Click delegation: +chat â insert "/see <id>" into composer; +mut â 3720// "/mut <id> ". User-edits-then-sends model â we never auto-send. 3721function onFeedClick(e) { 3722 const btn = e.target.closest(".feed-cell-btn"); 3723 if (!btn) return; 3724 const cell = btn.closest(".feed-cell"); 3725 const id = cell?.dataset.id; 3726 if (!id) return; 3727 if (cell.classList.contains("feed-cell-dim")) return; 3728 const action = btn.dataset.action; 3729 const m = document.getElementById("msg"); 3730 if (!m) return; 3731 if (action === "see") { 3732 const cur = m.value.trim(); 3733 m.value = cur ? `${cur} /see ${id}` : `/see ${id}`; 3734 } else if (action === "mut") { 3735 m.value = `/mut ${id} `; 3736 } 3737 m.focus(); 3738 resizeComposer(); 3739} 3740 3741// Feed grid kill-switch. Capped at 25 latest items â no infinite 3742// scroll, no pre-reserved 1600-slot scroll-area cushion. 3743const FEED_ENABLED = true; 3744// Pre-reserve enough empty cells to cover every available historical 3745// item (FEED_TOTAL_CAP = 1600). The grid's full final height is 3746// computed once at boot and never changes â scroll position is stable 3747// regardless of how fast pages stream in. Empty .feed-cell-empty 3748// divs are layout-cheap (no img decode, no GPU work) so 1600 of them 3749// is fine even on iOS. 3750const FEED_RESERVED_SLOTS = FEED_TOTAL_CAP; 3751const FEED_VIDEO_TAIL = 10; 3752const FEED_REVEAL_ROW_MS = 120; // ms between revealing each new row 3753async function bootFeedGrid() { 3754 if (!FEED_ENABLED) { console.log("[feed] disabled (localStorage.feed=off)"); return; } 3755 ensureFeedGrid(); 3756 feedGridEl?.addEventListener("click", onFeedClick); 3757 // Pre-reserve 200 empty slots so the grid's full layout height is 3758 // computed once at boot â scroll position never reflows as cells 3759 // fill in. 3760 reserveEmptySlots(FEED_RESERVED_SLOTS); 3761 // Eager-load up to FEED_TOTAL_CAP items by paging FEED_PAGE_SIZE at 3762 // a time. With cap=256 this is 8 pages of 32; each page also seeds 3763 // the matching server-side sprite map so the next refreshFeedSprite 3764 // points each cell at a cached webp. 3765 while (!feedExhausted && feedGridItems.size < FEED_TOTAL_CAP) { 3766 const before = feedGridItems.size; 3767 await loadFeedPage(); 3768 if (feedGridItems.size === before) break; 3769 } 3770} 3771 3772function reserveEmptySlots(n) { 3773 if (!feedGridEl) return; 3774 const frag = document.createDocumentFragment(); 3775 for (let i = 0; i < n; i++) { 3776 const cell = document.createElement("div"); 3777 cell.className = "feed-cell feed-cell-empty"; 3778 frag.appendChild(cell); 3779 } 3780 feedGridEl.appendChild(frag); 3781} 3782 3783async function loadVideoTail() { 3784 if (!feedGridEl) return; 3785 try { 3786 const r = await fetch(`/api/parascene/videos?limit=${FEED_VIDEO_TAIL}`); 3787 if (!r.ok) return; 3788 const j = await r.json(); 3789 const items = j.items || []; 3790 if (!items.length) return; 3791 const frag = document.createDocumentFragment(); 3792 for (const it of items) { 3793 const cell = document.createElement("div"); 3794 cell.className = "feed-cell feed-cell-video"; 3795 cell.dataset.id = String(it.id); 3796 cell.innerHTML = ` 3797 <video class="feed-cell-img" data-autoloop="1" muted loop playsinline preload="none" 3798 width="400" height="400" 3799 ${it.poster_url ? `poster="${esc(it.poster_url)}"` : ""} 3800 src="${esc(it.video_url)}"></video> 3801 <div class="feed-cell-id">${esc(String(it.id))}</div> 3802 <div class="feed-cell-actions"> 3803 <button type="button" class="feed-cell-btn" data-action="see" title="add to chat">+chat</button> 3804 <button type="button" class="feed-cell-btn" data-action="mut" title="mutate">+mut</button> 3805 </div>`; 3806 if (seenGenIds.has(String(it.id))) cell.classList.add("feed-cell-dim"); 3807 frag.appendChild(cell); 3808 feedGridItems.set(String(it.id), cell); 3809 } 3810 feedGridEl.appendChild(frag); 3811 } catch {} 3812} 3813const bootFeedRail = bootFeedGrid; // back-compat alias for boot block 3814 3815// âââââââââ boot âââââââââ 3816await loadAccount(); 3817// OAuth callback landings â read URL params (?signed_in=1, ?google_link_pending=1, ?auth_error=â¦) 3818// before connecting the WS, so any modal-driven action runs first. 3819await handleAuthLandings(); 3820refreshBotRoster(); 3821refreshTodoState(); 3822connectSignaling(); 3823// Feed rail â non-blocking initial fetch; live deltas arrive on WS. 3824bootFeedRail(); 3825resizeComposer(); 3826// Pre-position at the bottom on boot only. After that we never move the 3827// scroll â user owns it. The .booting class keeps the log invisible
3828// until chat-history arrives + we've scrolled; a 2s fallback reveals 3829// even if the WS never delivers history (empty room, offline, etc.). 3830scrollToBottom(); 3831requestAnimationFrame(() => { scrollToBottom(); }); 3832setTimeout(() => { scrollToBottom(); logEl.classList.remove("booting"); }, 2000); 3833 3834// Deep-link profile: slopstation.ai/<username> injects 3835// window.__OPEN_PROFILE__ via the server's fall-through route. If set, 3836// open that profile panel as soon as the page is interactive. 3837if (window.__OPEN_PROFILE__) { 3838 setTimeout(() => openProfilePanel(window.__OPEN_PROFILE__), 150); 3839} 3840 3841if (wantsCamera()) setTimeout(startCamera, 150); 3842 3843// Jank-isolation toggles â commented out, re-enable along with the 3844// matching HTML block in index.html. 3845// (function initDebugToggles() { 3846// const panel = document.getElementById("debugToggles"); 3847// if (!panel) return; 3848// let saved = {}; 3849// try { saved = JSON.parse(localStorage.getItem("dbgFlags") || "{}"); } catch {} 3850// for (const cb of panel.querySelectorAll("input[data-flag]")) { 3851// const flag = cb.dataset.flag; 3852// if (saved[flag]) { cb.checked = true; document.body.classList.add(flag); } 3853// cb.addEventListener("change", () => { 3854// document.body.classList.toggle(flag, cb.checked); 3855// saved[flag] = cb.checked; 3856// try { localStorage.setItem("dbgFlags", JSON.stringify(saved)); } catch {} 3857// }); 3858// } 3859// })();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.