clone

tiny dependency-free nostr libraries for plain html, each under 5kb and named for an element. public domain

Add oganesson: presence, typing and cursors on ephemeral events

Dami committed a718d76
commit
a718d767210bb588cd165bbfcff2c85b5e34eb60
parent
943f2b86b04cb5b48b4f630d994b3454e8982598
author
Dami <dami.etoile@gmail.com>
4 files changed, +347 -0
Ademo/oganesson.html64+++++++++++++++++
Aoganesson/README.md66+++++++++++++++++
Aoganesson/oganesson.js101++++++++++++++++++++++++++
Atest/oganesson.test.js116++++++++++++++++++++++++++++++
A demo/oganesson.html +64 -0
@@ -0,0 +1,64 @@
1+<!doctype html>2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>oganesson demo</title>7+<script src="relay.js"></script>8+<script src="../neon/neon.js"></script>9+<script src="../krypton/krypton.js"></script>10+<script src="../argon/argon.js"></script>11+<script src="../hydrogen/hydrogen.js"></script>12+<script src="../xenon/xenon.js"></script>13+<script src="../radon/radon.js"></script>14+<script src="../oganesson/oganesson.js"></script>15+<style>16+ :root { --ink:#1d1b16; --paper:#f4efe4; --line:#d8cfbd; --gas:#6d28d9; --muted:#7a7263; }17+ * { box-sizing:border-box; }18+ body { margin:0; background:var(--paper); color:var(--ink); font:15px/1.5 ui-serif, Georgia, serif; }19+ main { max-width:640px; margin:0 auto; padding:40px 20px 80px; }20+ h1 { font:600 13px/1 ui-monospace, Menlo, monospace; letter-spacing:.12em; text-transform:uppercase; margin:0 0 4px; }21+ h1 b { color:var(--gas); }22+ .sub { color:var(--muted); margin:0 0 28px; font-style:italic; }23+ select { font:inherit; text-transform:none; margin-left:8px; }24+ h2 { font:12px ui-monospace, Menlo, monospace; color:var(--muted); text-transform:uppercase; letter-spacing:.08em; margin:24px 0 8px; }25+ #here { display:flex; flex-wrap:wrap; gap:6px; list-style:none; padding:0; margin:0; min-height:28px; }26+ #here li b { font-weight:normal; }27+ #here li small { color:var(--muted); }28+ #here li { font:12px ui-monospace, Menlo, monospace; border:1px solid var(--line); border-radius:99px; padding:2px 10px; max-width:18ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }29+ textarea { width:100%; font:inherit; background:#fffdf8; border:1px solid var(--line); border-radius:6px; padding:8px 10px; min-height:60px; }30+ #typing { font:12px ui-monospace, Menlo, monospace; color:var(--muted); min-height:1.5em; }31+ #pad { position:relative; height:240px; background:#fffdf8; border:1px solid var(--line); border-radius:6px; overflow:hidden; }32+ #pad i { position:absolute; width:10px; height:10px; margin:-5px; border-radius:50%; background:var(--gas); pointer-events:none; transition:left .1s, top .1s; }33+</style>34+</head>35+<body>36+<main nog-room="oganesson-demo">37+ <h1><b>oganesson</b> demo</h1>38+ <p class="sub">presence, typing and cursors that vanish. Open in two browsers.</p>39+40+ <h2>here now <select id="away"><option>online</option><option>away</option></select></h2>41+ <ul id="here" nog-send="presence" nog-show="presence">42+ <template><li><b nrn-profile="{pubkey}" nrn-field="name">{npub}</b> <small>{status}</small></li></template>43+ </ul>44+45+ <h2>typing</h2>46+ <textarea id="say" nog-send="typing" placeholder="type something (only the fact that you type is sent)"></textarea>47+ <div id="typing" nog-show="typing">48+ <template><span><b nrn-profile="{pubkey}" nrn-field="name">someone</b> is typing </span></template>49+ </div>50+51+ <h2>cursors</h2>52+ <div id="pad" nog-send="cursor" nog-on="pointermove" nog-rate="100" nog-show="cursor" nog-ttl="10">53+ <template><i style="left:calc({x} * 100%); top:calc({y} * 100%)"></i></template>54+ </div>55+</main>56+<script>57+ // The heartbeat reads nog-status; send it now instead of waiting a minute.58+ away.onchange = ()=>{59+ here.setAttribute("nog-status", away.value)60+ oganesson.send(here, away.value)61+ }62+</script>63+</body>64+</html>
A oganesson/README.md +66 -0
@@ -0,0 +1,66 @@
1+# 👻 oganesson.js - live and ephemeral2+3+oganesson.js is a miniature of the live layer of a big chat app: who's here, who's typing, where their cursor is. Any element can send an ephemeral event (kinds 20000-29999) on a heartbeat or on input, and any element can show the latest one per person from a template, removing it when it goes stale or its author leaves. It publishes and subscribes through xenon.js, signs through the same `nhy:config` hook (so krypton.js or any other signer applies), and argon.js keeps none of it: argon keeps what lasts, oganesson handles what vanishes.4+5+```html6+<main nog-room="lobby">7+ <ul nog-send="presence" nog-show="presence">8+ <template><li><b nrn-profile="{pubkey}" nrn-field="name">{npub}</b> {status}</li></template>9+ </ul>10+ <textarea nog-send="typing"></textarea>11+ <p nog-show="typing"><template><span><b nrn-profile="{pubkey}" nrn-field="name">someone</b> is typing</span></template></p>12+</main>13+```14+15+The list announces you every minute and shows everyone else in the room, each once, until they go quiet or hide the page; the paragraph shows who is typing for five seconds after each keystroke.16+17+## Attributes18+19+| attribute | default | description |20+|---|---|---|21+| `nog-room` | none | Room id, sent as `["h", room]` and filtered with `#h`. Inherited from the closest ancestor. Without it events have no `h` tag and shows are not filtered by room |22+| `nog-send` | | Topic this element publishes |23+| `nog-on` | `input` on inputs, textareas and selects | Space-separated DOM events that publish. Pointer events send `{"x","y"}` as fractions of the element box. Without it (and not an input) the element sends a heartbeat every ttl/3 while the page is visible |24+| `nog-status` | `online` | Content of heartbeats, such as `away` |25+| `nog-show` | | Topic to show: one child per pubkey from the first element of the inner `<template>` (default `<span>{pubkey}</span>`), replaced on refresh, removed after the ttl |26+| `nog-ttl` | per topic | Seconds an event stays shown, and the heartbeat period (ttl/3) |27+| `nog-rate` | per topic | Minimum ms between sends. The latest throttled value is sent when the window ends |28+| `nog-self` | | Also show your own events (hidden by default; own pubkeys are learned from events you sign) |29+| `nog-ignore` | | oganesson skips this element and its descendants |30+31+Templates get xenon's fields (with neon.js `{npub}`, `{ago}`; with radon.js `nrn-profile` names), plus `{status}` and every key of JSON content, such as `{x}` and `{y}`, HTML-escaped. A cursor is `<i style="left:calc({x} * 100%); top:calc({y} * 100%)">`.32+33+## Topics34+35+| topic | kind | ttl | rate | content |36+|---|---|---|---|---|37+| `presence` | 20001 | 180 | 1000 | `online`, `away`, ... or `offline` |38+| `typing` | 20002 | 5 | 3000 | `typing` or `stopped` |39+| anything else | `nogCfg.kind` (20078) | 30 | 1000 | the status, or JSON such as `{"x":0.5,"y":0.25}`; tagged `["t", topic]` |40+41+Presence and typing follow the convention shared by Block's Buzz and tinyrelay. Other topics, such as cursors, are only a convention of ours, on one configurable kind (`window.nogCfg = {kind}`).42+43+## Events44+45+| event | cancel to | detail |46+|---|---|---|47+| `nog:send` | skip sending | `cfg` (unsigned `cfg.event`) |48+| `nog:shown` | | `event`, `fields`, `elt` |49+| `nog:expired` | keep the element | `pubkey`, `elt` |50+| `nog:error` | | `cfg`, `error` |51+| `nog:process` | | dispatch on an element to initialize new content |52+53+## Behavior54+55+- Readers drop events more than 5s in the future or older than their ttl, and events older than the one shown for that pubkey.56+- `offline` and `stopped` remove the entry at once. Reading also accepts JSON `{"status"}` and `{"typing":bool}`, and `false`, `idle`, `offline` as stopped typing.57+- Typing sends `stopped` when the field is emptied or blurred. Hiding the page sends `offline` for heartbeats and `stopped` for active typing; showing it sends a heartbeat.58+- Seeing a new pubkey makes this page's heartbeats in the same room and topic send right away, so newcomers don't wait a minute.59+- `oganesson.send(el, content)` publishes through `el`'s throttle, `oganesson.leave()` sends the goodbyes, `oganesson.mine` holds your pubkeys.60+61+## Caveats62+63+- Every heartbeat, keystroke burst and cursor move is a signature. A NIP-07 extension may prompt each time, so this works best with krypton.js's local key or an extension set to auto-approve these kinds.64+- Buzz and tinyrelay members-only rooms may require NIP-42 AUTH, which a future family library will handle.65+66+Requires xenon.js.
A oganesson/oganesson.js +101 -0
@@ -0,0 +1,101 @@
1+(()=>{2+ let doc = document, w = window3+ if (w.oganesson) return4+ let mine = new Set(), shows = new Set(), senders = new Set(), now = ()=>Date.now() / 1e3 | 05+ let send = (el, type, detail)=>el.dispatchEvent(new CustomEvent("nog:" + type, {detail, cancelable:1, bubbles:1, composed:1}))6+ let get = (el, a, d)=>el.getAttribute(a) ?? d7+ let spec = (t)=>t == "presence" ? [20001, 180, 1e3] : t == "typing" ? [20002, 5, 3e3] : [w.nogCfg?.kind ?? 20078, 30, 1e3, 1]8+ let room = (el)=>get(el.closest("[nog-room]") ?? el, "nog-room")9+ let ttl = (el, t)=>+get(el, "nog-ttl", spec(t)[1])10+ let hide = (el, pk)=>mine.has(pk) && !el.closest("[nog-self]")11+ let drop = (el, pk)=>{12+ let x = el.__nog[pk]13+ if (x?.n && send(el, "expired", {pubkey:pk, elt:x.n})) x.n = x.n.remove(clearTimeout(x.t))14+ }15+ let publish = async(el, content)=>{16+ let t = get(el, "nog-send"), [kind, , , custom] = spec(t), r = room(el), tags = [r && ["h", r], custom && ["t", t]].filter((x)=>x)17+ content = content.trim ? content : JSON.stringify(content)18+ let cfg = {elt:el, event:{kind, created_at:now(), tags, content}}19+ if (!send(el, "send", {cfg})) return20+ try {21+ let e = await hydrogen.sign(cfg), pk = e?.pubkey22+ if (!e) return23+ mine.has(pk) || shows.forEach((s)=>hide(s, pk) && drop(s, pk), mine.add(pk))24+ hydrogen.pub(e, cfg.relays)25+ } catch(error) {26+ send(el, "error", {cfg, error})27+ }28+ }29+ let fire = (el, data)=>{30+ el.__nd = [data]31+ if (el.__nt) return32+ publish(el, data)33+ el.__nd = 034+ el.__nt = setTimeout(()=>(el.__nt = 0, el.__nd && fire(el, el.__nd[0])), get(el, "nog-rate", spec(get(el, "nog-send"))[2]))35+ }36+ let stop = (el, s)=>publish(el, s, el.__nd = el.__ns = 0)37+ let initSend = (el)=>{38+ let t = get(el, "nog-send"), typing = t == "typing", on = get(el, "nog-on", /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) ? "input" : "")39+ let status = ()=>get(el, "nog-status", "online")40+ senders.add(el)41+ if (typing) el.addEventListener("blur", ()=>el.__ns && stop(el, "stopped"))42+ if (on) on.split(/\s+/).forEach((type)=>el.addEventListener(type, (evt)=>{43+ let r = el.getBoundingClientRect(), f = (v)=>+v.toFixed(3)44+ if (!typing) fire(el, "clientX" in evt ? {x:f((evt.clientX - r.left) / r.width), y:f((evt.clientY - r.top) / r.height)} : status())45+ else if (el.value) fire(el, "typing", el.__ns = 1)46+ else if (el.__ns) stop(el, "stopped")47+ }))48+ else {49+ let beat = el.__nb = ()=>el.isConnected ? !doc.hidden && fire(el, status()) : clearInterval(i)50+ let i = setInterval(beat, ttl(el, t) * 333)51+ beat()52+ }53+ }54+ let leave = ()=>senders.forEach((s)=>s.isConnected ? s.__nb ? stop(s, "offline") : s.__ns && stop(s, "stopped") : senders.delete(s))55+ let initShow = (el)=>{56+ let t = get(el, "nog-show"), [kind, , , custom] = spec(t), life = ttl(el, t), r = room(el), m = el.__nog = {}, f = {kinds:[kind], since:now() - life, ...r && {"#h":[r]}, ...custom && {"#t":[t]}}57+ let tpl = el.querySelector(":scope>template")?.innerHTML ?? "<span>{pubkey}</span>"58+ shows.add(el)59+ hydrogen.req({elt:el, filters:[f], onevent:(e, from, fields)=>{60+ let pk = e.pubkey, x = m[pk] ??= {}, at = e.created_at, s = e.content.trim(), n = doc.createElement("template"), o61+ if (hide(el, pk) || x.at > at || at > now() + 5 || at + life <= now()) return62+ x.at = at63+ try {64+ o = JSON.parse(s)65+ if (o && typeof o == "object"){66+ s = o.status ?? (o.typing == 0 ? "stopped" : "")67+ for (let k in o) fields[k] ??= hydrogen.esc(o[k])68+ }69+ } catch {}70+ if (kind == 20002) s = /^(stopped|false|idle|offline)$/i.test(s) ? "stopped" : "typing"71+ fields.status = hydrogen.esc(s ||= "online")72+ if (/^(offline|stopped)$/.test(s)) return drop(el, pk)73+ if (!x.n) senders.forEach((b)=>b.__nb && get(b, "nog-send") == t && room(b) == r && b.__nb())74+ n.innerHTML = tpl.replace(/\{(#?\w+)\}/g, (a, k)=>fields[k] ?? a)75+ if (!(n = n.content.firstElementChild)) return76+ n.setAttribute("nog-pk", pk)77+ x.n?.isConnected ? x.n.replaceWith(n) : el.append(n)78+ clearTimeout(x.t)79+ x.t = setTimeout(()=>drop(el, pk), (at + life) * 1e3 - Date.now())80+ send(el, "shown", {event:e, fields, elt:x.n = n})81+ }})82+ }83+ let init = (el)=>{84+ if (el.__ni || el.closest("[nog-ignore]")) return85+ el.__ni = 186+ if (el.hasAttribute("nog-show")) initShow(el)87+ if (el.hasAttribute("nog-send")) initSend(el)88+ }89+ let sel = "[nog-send],[nog-show]"90+ let process = (n)=>{91+ if (n.matches?.(sel)) init(n)92+ n.querySelectorAll?.(sel).forEach(init)93+ }94+ w.oganesson = {send:fire, leave, mine}95+ doc.addEventListener("nog:process", (evt)=>process(evt.target))96+ doc.addEventListener("visibilitychange", ()=>doc.hidden ? leave() : senders.forEach((s)=>s.__nb?.()))97+ doc.addEventListener("DOMContentLoaded", ()=>{98+ new MutationObserver((recs)=>recs.forEach((r)=>r.addedNodes.forEach(process))).observe(doc.documentElement, {childList:1, subtree:1})99+ process(doc.body)100+ })101+})()
A test/oganesson.test.js +116 -0
@@ -0,0 +1,116 @@
1+// Two browsers (separate localStorage, so separate krypton keys) meet on demo/oganesson.html.2+import { assert, assertEquals } from "jsr:@std/assert@1";3+import { chromium } from "npm:playwright@1.58";4+import { serve } from "../server.ts";5+6+// Use CHROMIUM if set, else the newest headless shell already in the Playwright cache.7+async function chromiumPath() {8+ if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");9+ const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;10+ try {11+ const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();12+ const dir = `${cache}/${dirs.at(-1)}`;13+ const arch = [...Deno.readDirSync(dir)][0].name;14+ return `${dir}/${arch}/chrome-headless-shell`;15+ } catch { return undefined; }16+}17+18+const pks = (page, sel) => page.evaluate((s) => [...document.querySelectorAll(s)].map((e) => e.getAttribute("nog-pk")), sel);19+20+const has = (page, sel, pk) => page.evaluate(([s, pk]) => !!document.querySelector(`${s} [nog-pk="${pk}"]`), [sel, pk]);21+const setHidden = (page, hidden) => page.evaluate((h) => {22+ Object.defineProperty(document, "hidden", { value: h, configurable: true });23+ document.dispatchEvent(new Event("visibilitychange"));24+}, hidden);25+26+Deno.test({ name: "oganesson: presence, typing, cursors, nothing stored", sanitizeResources: false, sanitizeOps: false }, async (t) => {27+ const server = serve(0);28+ const url = `http://localhost:${server.addr.port}/demo/oganesson.html`;29+ const browser = await chromium.launch({ executablePath: await chromiumPath() });30+ const errors = [];31+ const open = async () => {32+ const page = await (await browser.newContext()).newPage();33+ page.on("pageerror", (e) => errors.push(e.message));34+ await page.addInitScript(() => {35+ window.__got = [];36+ document.addEventListener("nog:shown", (e) => window.__got.push(e.detail.event));37+ });38+ await page.goto(url);39+ return page;40+ };41+ try {42+ const a = await open(), b = await open();43+ const pkA = await a.evaluate(() => krypton.signer.getPublicKey());44+ const pkB = await b.evaluate(() => krypton.signer.getPublicKey());45+ assert(pkA !== pkB, "separate contexts have separate keys");46+ const got = (kind) => a.evaluate((k) => window.__got.filter((e) => e.kind === k), kind);47+48+ await t.step("each sees the other present at once, not themselves", async () => {49+ // Far sooner than the 60s heartbeat: seeing a newcomer triggers a heartbeat back.50+ await a.waitForFunction((pk) => document.querySelector(`#here [nog-pk="${pk}"]`), pkB, { timeout: 3000 });51+ await b.waitForFunction((pk) => document.querySelector(`#here [nog-pk="${pk}"]`), pkA, { timeout: 3000 });52+ assertEquals(await pks(a, "#here li"), [pkB]);53+ assertEquals(await pks(b, "#here li"), [pkA]);54+ assertEquals(await a.textContent("#here li small"), "online");55+ const e = (await got(20001)).at(-1);56+ assertEquals([e.kind, e.tags, e.content], [20001, [["h", "oganesson-demo"]], "online"]);57+ });58+59+ await t.step("a status change replaces the entry and fills {status}", async () => {60+ await b.selectOption("#away", "away");61+ await a.waitForFunction(() => document.querySelector("#here li small")?.textContent === "away");62+ assertEquals(await pks(a, "#here li"), [pkB]);63+ });64+65+ await t.step("typing shows, refreshes in place, and stops when emptied", async () => {66+ await b.type("#say", "hi");67+ await a.waitForFunction((pk) => document.querySelector(`#typing [nog-pk="${pk}"]`), pkB);68+ assertEquals(await b.locator("#typing span").count(), 0, "own typing hidden");69+ await b.waitForTimeout(3200); // past the 3s throttle70+ await b.type("#say", "!");71+ await a.waitForTimeout(500);72+ assertEquals(await pks(a, "#typing span"), [pkB]);73+ const e = (await got(20002)).at(-1);74+ assertEquals([e.tags, e.content], [[["h", "oganesson-demo"]], "typing"]);75+ await b.fill("#say", "");76+ await a.waitForFunction(() => !document.querySelector("#typing span"), null, { timeout: 1500 });77+ });78+79+ await t.step("typing expires after its 5s ttl", async () => {80+ await b.waitForTimeout(3100);81+ await b.type("#say", "x");82+ await a.waitForFunction(() => document.querySelector("#typing span"));83+ const t0 = Date.now();84+ await a.waitForFunction(() => !document.querySelector("#typing span"), null, { timeout: 7000 });85+ assert(Date.now() - t0 > 3000, "stays until its ttl");86+ });87+88+ await t.step("cursors carry {x} and {y} on the custom kind", async () => {89+ const box = await b.locator("#pad").boundingBox();90+ await b.mouse.move(box.x + box.width / 4, box.y + box.height / 2);91+ await a.waitForFunction(() => document.querySelector("#pad i")?.getAttribute("style").includes("left:calc(0.25 * 100%)"));92+ assert((await a.locator("#pad i").getAttribute("style")).includes("top:calc(0.5 * 100%)"));93+ await b.mouse.move(box.x + box.width * 0.75, box.y + box.height / 2, { steps: 5 });94+ await a.waitForFunction(() => document.querySelector("#pad i").getAttribute("style").includes("left:calc(0.75 * 100%)"));95+ assertEquals(await pks(a, "#pad i"), [pkB]);96+ assertEquals((await got(20078)).at(-1).tags, [["h", "oganesson-demo"], ["t", "cursor"]]);97+ });98+99+ await t.step("a hidden page goes offline at once and returns when visible", async () => {100+ await setHidden(b, true);101+ await a.waitForFunction((pk) => !document.querySelector(`#here [nog-pk="${pk}"]`), pkB, { timeout: 2000 });102+ assert(await has(b, "#here", pkA), "hidden B keeps its list");103+ await setHidden(b, false);104+ await a.waitForFunction((pk) => document.querySelector(`#here [nog-pk="${pk}"]`), pkB, { timeout: 3000 });105+ });106+107+ await t.step("argon stores none of it", async () => {108+ for (const p of [a, b]) assertEquals(await p.evaluate(() => argon.query([{ kinds: [20001, 20002, 20078] }])), []);109+ });110+111+ assertEquals(errors, []);112+ } finally {113+ await browser.close();114+ await server.shutdown();115+ }116+});