// The WAI widgets of wai-app's registry, as components of the streaming renderer. // // A room widget (Room, WatchTogether, Show) runs the room client of joule-moq-web in a frame // of this page's own origin (room-frame.html): the page that makes its own key, asks the // room service for a grant, proves the key to the relay, checks every audio and video // object against its signed receipt before it is shown, keeps a watch room in sync and // follows a show's polls. The widgets inside the room drive that client through its own // interface (`roomTest`), from this page: every action a person takes (join, watch, leave, // send a chat, open a poll, vote, play, pause) goes through the page's session and is // sealed into its log (session.js). const h = (tag, attrs = {}, ...kids) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; if (k === "class") el.className = v; else if (k === "text") el.textContent = v; else if (k.startsWith("on")) el.addEventListener(k.slice(2), v); else el.setAttribute(k, v === true ? "" : String(v)); } for (const c of kids) if (c != null) el.append(c); return el; }; const short = (p) => (p ? `${p.slice(0, 8)}…` : ""); const text = (props, k, d = "") => (typeof props[k] === "string" ? props[k] : d); // ---------------------------------------------------------------------------- the room // The page's one room: the widget that holds it sets it, the widgets inside it read it. export class RoomSlot { constructor() { this.current = null; this.subs = new Set(); } set(handle) { this.current = handle; for (const f of this.subs) f(handle); } clear(handle) { if (this.current === handle) this.set(null); } on(f) { this.subs.add(f); if (this.current) f(this.current); return () => this.subs.delete(f); } } // The room client in its frame, and what the widgets ask of it. export class RoomHandle { constructor(kind, frame) { this.kind = kind; this.frame = frame; this.subs = new Set(); this.last = null; this.timer = setInterval(() => this.tick(), 600); } get t() { try { return this.frame.contentWindow && this.frame.contentWindow.roomTest; } catch { return null; } } // Ready to enter: the client has read its room service and waits for a role. async ready(ms = 30000) { const end = Date.now() + ms; for (;;) { const t = this.t; if (t && t.enter && t.state && t.state.phase === "idle") return t; if (t && t.state && t.state.phase === "refused") throw new Error("the room client refused its endpoints"); if (Date.now() > end) throw new Error("the room client did not load"); await new Promise((r) => setTimeout(r, 150)); } } async enter(role) { const t = await this.ready(); const phase = await t.enter(role); this.tick(); return { phase, principal: this.me() }; } async leave() { const t = this.t; if (t && t.leave) await t.leave(); this.tick(); return "left"; } state() { const t = this.t; return (t && t.state) || null; } phase() { const s = this.state(); return s ? s.phase : "loading"; } me() { return (this.last && this.last.me && this.last.me.principal) || null; } role() { const t = this.t; return t && t.role; } snapshot() { try { return this.t && this.t.snapshot ? this.t.snapshot() : null; } catch { return null; } } chats() { const s = this.state(); return ((s && s.timeline) || []).filter((e) => e.kind === "chat").map((e) => ({ t: e.t, who: e.who, text: e.payload && e.payload.text, digest: e.digest })); } async sendChat(text) { const v = await this.t.sendChat(text); this.tick(); return { t: v && v.t, digest: v && v.digest }; } co() { try { return this.t && this.t.coState ? this.t.coState() : null; } catch { return null; } } async openPoll(question, options, seconds) { const r = await this.t.coRequest("/co/window", { rule: "plurality", options, k: 1, seconds, question }); if (r.status !== 200) throw new Error(`the room's clock answered ${r.status}: ${JSON.stringify(r.body)}`); return { status: r.status }; } async vote(window, option) { const v = await this.t.coInput({ window, type: "vote", option }); return { window, option, t: v && v.t }; } async watch(name, arg) { const at = await this.t.watchControl(name, arg); return { name, arg, at }; } // The watch room's player position, in microseconds. position() { try { const m = this.frame.contentDocument.getElementById("cowatch-media"); return Math.round((m.currentTime || 0) * 1e6); } catch { return 0; } } // Objects received and checked against their receipts, over every participant and track. checked() { const s = this.state(); const out = { received: 0, verified: 0, refused: 0 }; for (const sub of Object.values((s && s.subs) || {})) { for (const m of ["audio", "video", "screen"]) { const x = sub[m]; if (!x) continue; out.received += x.received || 0; out.verified += x.verified || 0; out.refused += x.refused || 0; } } return out; } present() { const s = this.state(); return ((s && s.room && s.room.present) || []).map((p) => p.principal); } setView(view) { try { this.frame.contentDocument.body.dataset.view = view; } catch { /* not loaded yet: the query has it */ } } tick() { this.last = this.snapshot(); for (const f of this.subs) { try { f(this); } catch { /* a view's own fault */ } } } on(f) { this.subs.add(f); f(this); return () => this.subs.delete(f); } destroy() { clearInterval(this.timer); this.subs.clear(); try { if (this.t && this.t.leave && ["publishing", "watching"].includes(this.phase())) this.t.leave(); } catch { /* gone */ } } } const KIND = { Room: { kind: "live", label: "Live room", join: "Join with camera and microphone", watch: "Watch", view: "none" }, WatchTogether: { kind: "watch", label: "Watching together", join: null, watch: "Watch together", view: "watch" }, Show: { kind: "show", label: "Show", join: "Join as a host", watch: "Watch", view: "none" }, }; const PHASE = { loading: "The room client is loading…", idle: "Not in the room yet.", entering: "Entering…", publishing: "In the room, on camera and microphone.", watching: "Watching.", full: "The room is full: you can watch.", left: "You left the room.", refused: "This page cannot reach the room service.", }; function roomWidget(name) { const k = KIND[name]; return (ctx, props) => { const title = h("h2", { class: "wai-room-title", text: text(props, "title", k.label) }); const status = h("span", { class: "wai-room-status", role: "status", "aria-live": "polite", text: PHASE.loading }); const join = k.join ? h("button", { type: "button", class: "wai-btn wai-primary", "data-act": "join", text: k.join }) : null; const watch = h("button", { type: "button", class: "wai-btn", "data-act": "watch", text: k.watch }); const leave = h("button", { type: "button", class: "wai-btn", "data-act": "leave", hidden: true, text: "Leave" }); const frame = h("iframe", { class: "wai-frame", title: `${k.label}: the room`, allow: "camera; microphone; autoplay", loading: "eager" }); const stage = h("div", { class: "wai-room-stage", "data-view": k.view }, frame); const slot = h("div", { class: "wai-room-children" }); const el = h("section", { class: "wai-room", "data-kind": k.kind }, h("header", { class: "wai-room-head" }, title, h("span", { class: "wai-kind", text: k.label })), h("div", { class: "wai-room-controls" }, join, watch, leave, status), stage, slot); const u = new URL(ctx.config.frame, location.href); u.searchParams.set("service", (ctx.config.services && ctx.config.services[k.kind]) || ctx.config.service); u.searchParams.set("view", k.view); frame.src = u.toString(); const handle = new RoomHandle(k.kind, frame); handle.stage = stage; handle.views = new Set(); handle.showVideo = (on) => { if (on) handle.views.add("video"); else handle.views.delete("video"); const view = k.view === "watch" ? "watch" : handle.views.size ? "stage" : "none"; stage.dataset.view = view; handle.setView(view); }; // The frame's height follows its content. const fit = () => { try { const d = frame.contentDocument; if (d && d.body) frame.style.height = `${Math.max(0, d.body.scrollHeight)}px`; } catch { /* not ours yet */ } }; frame.addEventListener("load", () => { handle.setView(stage.dataset.view); try { new ResizeObserver(fit).observe(frame.contentDocument.body); } catch { /* no observer */ } fit(); }); const busy = (b) => { for (const x of [join, watch, leave]) if (x) x.disabled = b; }; const act = (action, role) => async () => { busy(true); try { if (role) await ctx.session.fire(action, { room: k.kind, role }, () => handle.enter(role)); else await ctx.session.fire(action, { room: k.kind }, () => handle.leave()); } catch (e) { status.textContent = `${PHASE[handle.phase()] || ""} ${e.message || e}`; } busy(false); render(); }; if (join) join.addEventListener("click", act("room.join", "publisher")); watch.addEventListener("click", act("room.watch", "viewer")); leave.addEventListener("click", act("room.leave", null)); const render = () => { const p = handle.phase(); const inRoom = p === "publishing" || p === "watching"; status.textContent = PHASE[p] || p; if (join) join.hidden = inRoom || p === "left"; watch.hidden = inRoom || p === "left"; leave.hidden = !inRoom; el.dataset.phase = p; }; handle.on(render); ctx.room.set(handle); return { el, slot, handle, update(next) { title.textContent = text(next, "title", k.label); }, destroy() { ctx.room.clear(handle); handle.destroy(); }, }; }; } // The widgets inside a room find it through the page's room slot. function inRoom(ctx, draw) { let handle = null; let off = null; const offSlot = ctx.room.on((hd) => { if (off) off(); handle = hd; off = hd ? hd.on(() => draw(hd)) : null; if (!hd) draw(null); }); return { get handle() { return handle; }, stop() { if (off) off(); offSlot(); } }; } // ---------------------------------------------------------------------------- the widgets function App(ctx, props) { const title = h("h1", { class: "wai-app-title", text: text(props, "title") }); const slot = h("div", { class: "wai-app-children" }); const el = h("div", { class: "wai-app-shell" }, title, slot); return { el, slot, update(p) { title.textContent = text(p, "title"); } }; } function Text(ctx, props) { const el = h("p", { class: "wai-text", text: text(props, "text") }); return { el, update(p) { el.textContent = text(p, "text"); } }; } function Player(ctx, props) { const el = h("div", { class: "wai-player", text: text(props, "title", "The room's video") }); const r = inRoom(ctx, () => {}); const on = ctx.room.on((hd) => { if (hd && hd.showVideo) hd.showVideo(true); }); return { el, update(p) { el.textContent = text(p, "title", "The room's video"); }, destroy() { on(); const hd = r.handle; if (hd && hd.showVideo) hd.showVideo(false); r.stop(); }, }; } function Chat(ctx, props) { const head = h("h3", { class: "wai-h", text: text(props, "title", "Chat") }); const list = h("ul", { class: "wai-chat-list", "aria-live": "polite" }); const input = h("input", { class: "wai-input", maxlength: "500", autocomplete: "off", "aria-label": "Your message" }); const send = h("button", { type: "submit", class: "wai-btn", text: "Send" }); const note = h("p", { class: "wai-note" }); const form = h("form", { class: "wai-chat-form" }, input, send); const el = h("section", { class: "wai-chat" }, head, list, form, note); let shown = 0; const r = inRoom(ctx, (hd) => { const chats = hd ? hd.chats() : []; if (chats.length !== shown) { list.replaceChildren(...chats.map((c) => h("li", { "data-digest": c.digest || "" }, h("b", { text: `${c.who}: ` }), c.text || ""))); shown = chats.length; list.scrollTop = list.scrollHeight; } const p = hd ? hd.phase() : "loading"; const can = p === "publishing"; input.disabled = send.disabled = !can; note.textContent = can ? "Each message is signed by your key and checked by everyone before it shows." : p === "watching" ? "Viewers read the chat; those who join write it." : "Join the room to write."; }); form.addEventListener("submit", async (e) => { e.preventDefault(); const t = input.value.trim(); const hd = r.handle; if (!t || !hd) return; input.value = ""; try { await ctx.session.fire("chat.send", { text: t }, () => hd.sendChat(t)); } catch (err) { note.textContent = String(err.message || err); } }); return { el, update(p) { head.textContent = text(p, "title", "Chat"); }, destroy() { r.stop(); } }; } function Poll(ctx, props) { let p = props; const q = h("h3", { class: "wai-h wai-poll-q" }); const opts = h("div", { class: "wai-poll-options" }); const open = h("button", { type: "button", class: "wai-btn wai-primary", hidden: true, text: "Open the poll" }); const result = h("p", { class: "wai-poll-result", role: "status", "aria-live": "polite" }); const note = h("p", { class: "wai-note" }); const el = h("section", { class: "wai-poll" }, q, opts, open, result, note); const options = () => (Array.isArray(p.options) ? p.options : []).map(String); const question = () => text(p, "question"); const seconds = () => (typeof p.seconds === "number" ? p.seconds : 30); let voted = null; const draw = (hd) => { q.textContent = question(); const co = hd ? hd.co() : null; const labels = options(); const windows = co ? co.windows.filter((w) => w.question === question()) : []; const w = windows[windows.length - 1] || null; const now = co ? co.now : 0; const isOpen = !!w && w.close > now; const audience = co && co.audience && co.audience.limit > 0; open.hidden = !(co && co.lead) || isOpen; open.disabled = false; if (opts.dataset.labels !== JSON.stringify(labels) || opts.dataset.open !== String(isOpen && audience)) { opts.dataset.labels = JSON.stringify(labels); opts.dataset.open = String(isOpen && audience); opts.replaceChildren(...labels.map((label, i) => { const b = h("button", { type: "button", class: "wai-btn wai-option", "data-option": String(i), text: label, disabled: !(isOpen && audience) }); b.addEventListener("click", async () => { const ww = w; if (!ww || !hd) return; for (const x of opts.querySelectorAll("button")) x.disabled = true; try { await ctx.session.fire("poll.vote", { question: question(), option: i, label }, () => hd.vote(ww.digest, i)); voted = i; } catch (e) { note.textContent = String(e.message || e); } }); return b; })); } if (w && w.selection) { const pick = w.selection.picks[0]; const credit = w.selection.credits[0]; result.textContent = pick && pick.option != null ? `The room chose “${labels[pick.option] || `option ${pick.option + 1}`}”, with ${credit ? credit.count : 0} vote${credit && credit.count === 1 ? "" : "s"}; the room's clock signed the result.` : "The poll closed with no vote."; el.dataset.state = "closed"; } else if (isOpen) { result.textContent = `Open: closes in ${Math.max(0, Math.round((w.close - now) / 1e6))} s · ${w.inputs} vote${w.inputs === 1 ? "" : "s"} so far${voted != null ? ` · you chose “${labels[voted]}”` : ""}`; el.dataset.state = "open"; } else if (w) { result.textContent = "Closed: the room's clock is counting."; el.dataset.state = "counting"; } else { result.textContent = co ? (co.lead ? "Open it when you are ready." : "The host opens the poll.") : "Join or watch the show to take part."; el.dataset.state = "waiting"; } note.textContent = audience ? "Your vote is signed by your key; the room's clock selects and signs the result." : co && co.lead ? "You lead this show: you open its polls." : ""; }; const r = inRoom(ctx, draw); open.addEventListener("click", async () => { const hd = r.handle; if (!hd) return; open.disabled = true; try { await ctx.session.fire("poll.open", { question: question(), options: options() }, () => hd.openPoll(question(), options().length, seconds())); } catch (e) { note.textContent = String(e.message || e); } }); draw(null); return { el, update(next) { p = next; draw(r.handle); }, destroy() { r.stop(); } }; } function Participants(ctx, props) { const head = h("h3", { class: "wai-h", text: text(props, "title", "In the room") }); const list = h("ul", { class: "wai-people" }); const el = h("section", { class: "wai-participants" }, head, list); const r = inRoom(ctx, (hd) => { const ps = hd ? hd.present() : []; const me = hd ? hd.me() : null; const json = JSON.stringify([ps, me]); if (list.dataset.v === json) return; list.dataset.v = json; list.replaceChildren(...(ps.length ? ps.map((p) => h("li", { "data-principal": p, text: p === me ? `you (${short(p)})` : short(p) })) : [h("li", { class: "wai-note", text: "No one yet." })])); }); return { el, update(p) { head.textContent = text(p, "title", "In the room"); }, destroy() { r.stop(); } }; } function Receipts(ctx, props) { const head = h("h3", { class: "wai-h", text: text(props, "title", "Receipts") }); const verdict = h("p", { class: "wai-verdict", role: "status" }); const table = h("table", { class: "wai-log" }, h("thead", {}, h("tr", {}, ...["#", "Action", "Verdict", "Energy", "Link"].map((t) => h("th", { text: t }))))); const body = h("tbody"); table.append(body); const room = h("p", { class: "wai-note" }); const el = h("section", { class: "wai-receipts" }, head, verdict, h("div", { class: "wai-scroll" }, table), room); const draw = () => { const log = ctx.session.log(); const v = ctx.session.verify(); verdict.textContent = v.verified != null ? `${v.verified} action${v.verified === 1 ? "" : "s"} sealed by this page's key (${short(ctx.session.actor.replace("did:key:", ""))}), the chain verified link by link.` : `The chain breaks at link ${v.broken_at}.`; verdict.dataset.ok = String(v.verified != null); body.replaceChildren(...log.slice(-12).map((e) => h("tr", { "data-seq": String(e.seq) }, h("td", { text: String(e.seq) }), h("td", { text: e.action }), h("td", { text: e.verdict }), h("td", { text: `${e.energy.estimated_uj} µJ (${e.energy.provenance})` }), h("td", { class: "wai-mono", text: e.id.slice(3, 15) })))); }; const off = ctx.session.on(draw); const r = inRoom(ctx, (hd) => { if (!hd) { room.textContent = ""; return; } const c = hd.checked(); room.textContent = `The room: ${c.verified} of ${c.received} objects received checked against their receipts${c.refused ? `, ${c.refused} refused` : ""}.`; }); draw(); return { el, update(p) { head.textContent = text(p, "title", "Receipts"); }, destroy() { off(); r.stop(); } }; } function EnergyMeter(ctx, props) { const head = h("h3", { class: "wai-h", text: text(props, "title", "Energy") }); const total = h("p", { class: "wai-energy-total" }); const list = h("ul", { class: "wai-energy" }); const note = h("p", { class: "wai-note", text: "A page has no energy meter: each figure is an estimate (the action's time on this page at 20 W), labelled Estimator, never a reading." }); const el = h("section", { class: "wai-energymeter" }, head, total, list, note); const draw = () => { const log = ctx.session.log(); const by = new Map(); for (const e of log) if (e.verdict === "allow") by.set(e.action, (by.get(e.action) || 0) + e.energy.estimated_uj); const sum = ctx.session.totalUj(); total.textContent = `${(sum / 1e6).toFixed(3)} J over ${log.length} action${log.length === 1 ? "" : "s"} (Estimator)`; list.replaceChildren(...[...by.entries()].map(([a, uj]) => h("li", { text: `${a}: ${(uj / 1e3).toFixed(1)} mJ` }))); }; const off = ctx.session.on(draw); draw(); return { el, update(p) { head.textContent = text(p, "title", "Energy"); }, destroy() { off(); } }; } function WatchTogetherControls(ctx) { // The shared player's controls, each a sealed action: they apply to everyone in the room. const mk = (label, act) => h("button", { type: "button", class: "wai-btn", "data-act": act, text: label }); const play = mk("Play", "play"), pause = mk("Pause", "pause"), back = mk("Back 10 s", "back"), fwd = mk("Ahead 10 s", "ahead"); const el = h("div", { class: "wai-watch-controls", hidden: true }, play, pause, back, fwd); const r = inRoom(ctx, (hd) => { el.hidden = !hd || hd.phase() !== "watching"; }); const go = (name, arg) => async () => { const hd = r.handle; if (!hd) return; const a = typeof arg === "function" ? arg(hd) : arg; try { await ctx.session.fire(`watch.${name}`, { arg: a }, () => hd.watch(name, a)); } catch { /* shown by the room */ } }; play.addEventListener("click", go("play", 0)); pause.addEventListener("click", go("pause", 0)); back.addEventListener("click", go("seek", (hd) => Math.max(0, hd.position() - 10e6))); fwd.addEventListener("click", go("seek", (hd) => hd.position() + 10e6)); return { el, stop: () => r.stop() }; } function WatchTogether(ctx, props, node) { const c = roomWidget("WatchTogether")(ctx, props, node); const controls = WatchTogetherControls(ctx); c.el.insertBefore(controls.el, c.slot); const destroy = c.destroy; c.destroy = () => { controls.stop(); destroy(); }; return c; } export const components = { App, Text, Room: roomWidget("Room"), WatchTogether, Show: roomWidget("Show"), Player, Chat, Poll, Participants, Receipts, EnergyMeter, }; // The context the components share: the page's session, its one room, and where the room // service (`service`, or one for each kind of room, `services`) and the room client's frame // are, each on this page's origin. export function context({ session, service, services = {}, frame }) { return { session, room: new RoomSlot(), config: { service, services, frame } }; }