aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md1
-rw-r--r--README.md21
-rw-r--r--blossom/compose-blossom.yml23
-rw-r--r--blossom/server.js281
-rw-r--r--bridge/.gitignore2
-rw-r--r--bridge/config.example.json12
-rw-r--r--bridge/ln.js116
-rw-r--r--bridge/server.js373
-rw-r--r--names/compose-libretranslate.yml26
-rw-r--r--names/ownership.js10
-rw-r--r--names/preview.js133
-rw-r--r--names/server.js1840
-rw-r--r--names/translate.js123
-rw-r--r--nwcpush/.gitignore2
-rw-r--r--nwcpush/config.example.json8
-rw-r--r--nwcpush/server.js529
-rw-r--r--src/app.js144
-rw-r--r--src/features/messages.js1144
-rw-r--r--src/feed-cache.js2
-rw-r--r--src/i18n.js8
-rw-r--r--src/media-upload.js46
-rw-r--r--src/negentropy.js197
-rw-r--r--src/nostr.js94
-rw-r--r--src/style.css77
-rw-r--r--src/thread-prefetch.js8
-rw-r--r--src/video-compress.js63
-rw-r--r--tools/deepl-translate.test.js38
-rw-r--r--tools/feed-appetite-test.js159
-rw-r--r--tools/feed-conversations-test.js133
-rw-r--r--tools/feed-media-views-test.js11
-rw-r--r--tools/feed-momentum-test.js48
-rw-r--r--tools/feed-picture-pager-test.js1
-rw-r--r--tools/feed-wheel-reverse-test.js90
-rw-r--r--tools/hls-upload-test.js92
-rw-r--r--tools/negentropy-sync-test.js159
-rw-r--r--tools/negentropy-test.js64
-rw-r--r--tools/pager-caption-test.js134
-rw-r--r--tools/pager-first-frame-test.js124
-rw-r--r--tools/pager-gone-picture-test.js19
-rw-r--r--tools/pager-loading-test.js2
-rw-r--r--tools/pager-no-poster-test.js123
-rw-r--r--tools/pager-ready-gate-test.js153
-rw-r--r--tools/pager-seek-hide-test.js129
-rw-r--r--tools/pager-swipe-fly-test.js97
-rw-r--r--tools/pager-tap-slop-test.js1
-rw-r--r--tools/picture-pager-ready-test.js102
-rw-r--r--tools/profile-pager-test.js16
-rw-r--r--tools/quote-pager-test.js1
-rw-r--r--tools/registry-ownership-test.js18
-rw-r--r--tools/sync-blob-test.js10
-rw-r--r--tools/thread-instant-test.js158
-rw-r--r--tools/thread-prefetch-test.js10
-rw-r--r--tools/video-fallback-test.js6
-rw-r--r--tools/video-hls-play-test.js133
-rw-r--r--tools/vmedia-hls-test.js84
-rw-r--r--vmedia/server.js231
56 files changed, 3678 insertions, 3951 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6519e54..15f1c65 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,3 +4,4 @@
- Deploy only the requested changes; preserve unrelated work in the shared workspace. Use an isolated release worktree when necessary.
- Production deploys through `git push prod HEAD:prod` (`prod` points to `cs:coinosv3.git`). The remote post-receive hook builds and publishes the site at https://v3.coinos.io. Verify the live assets and matching service-worker version after deployment.
- Keep `master` and `prod` identical (user's instruction, 2026-10-01). Build release worktrees from `master` (after `git fetch prod` and checking `prod/prod` is not ahead). After the prod push succeeds, fast-forward `master` to the deployed commit and push it to `origin`: `git push prod HEAD:prod && git push origin HEAD:master`. If the shared checkout has uncommitted work on `master`, move the ref with `git update-ref` and carry the working-tree edits over with a three-way merge per file (`git merge-file`); never stash or reset them away.
+- Server-side services (names registrar, nwcpush, blossom, bridge; feed and vmedia are moving there too) live in the `coinosv3-server` repository (clone at `~/coinosv3-server`, remotes `prod` = `cs:coinosv3-server.git`, `origin` = GitHub). Deploy them with `git push prod HEAD:prod && git push origin HEAD:master` from that clone; its post-receive hook restarts only the services whose files changed. Never scp files into `~/arkswap` or `~/coinosv3-server` on cs (user's instruction, relayed 2026-10-16).
diff --git a/README.md b/README.md
index 4593506..4227479 100644
--- a/README.md
+++ b/README.md
@@ -221,17 +221,24 @@ bun run build # → dist/
save it and open it from disk with no server.
-## Services in this repo
+## Server-side services
-The app is static; these small services run beside it (Bun):
+The app is static. The small services that run beside it (Bun) live in
+their own repository, **[coinosv3-server](https://github.com/coinos/coinosv3-server)**
+(also at https://git.coinos.io/coinosv3-server.git): the payment-name
+registrar (`names.coinos.io`: BIP-353 records, LNURL-pay for `@coinos.io`
+names, link previews, translation), the NWC push notifier (`nwcpush`), the
+Blossom server for encrypted sync envelopes (`blossom.coinos.io`), the
+public feed and server-rendered note pages, video help (`vmedia`) and the
+retired Ark→Lightning bridge. They import the wallet's own modules
+(`src/ark/*`, `src/noffer.js`, …) through a pinned `coinosv3` submodule.
+
+Two of them still have a copy here until their deployments move over:
| Directory | Service |
| --- | --- |
-| `names/` | Payment-name registrar (`names.coinos.io`): BIP-353 TXT records via the DNS provider's API, LNURL-pay for `@coinos.io` names, NIP-98 auth, link previews |
-| `feed/` | Public feed snapshot (`/api/feed`) and server-rendered note pages |
-| `nwcpush/` | Watches relays for NWC requests and push notifications, and wakes devices with Web Push |
-| `blossom/` | `blossom.coinos.io`: a Blossom server that only stores encrypted sync envelopes |
-| `bridge/` | Ark→Lightning swap bridge (retired; the app uses the Ark server's Lightning) |
+| `feed/` | Public feed snapshot (`/api/feed`) and server-rendered note and profile pages |
+| `vmedia/` | Server posters and H.264 copies for videos some browsers play black |
## How state is kept
diff --git a/blossom/compose-blossom.yml b/blossom/compose-blossom.yml
deleted file mode 100644
index 28acbad..0000000
--- a/blossom/compose-blossom.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-# blossom.coinos.io — Blossom blob server for oversized wallet-sync domains.
-# Stores nothing but the wallet's encrypted sync envelopes (see server.js).
-# Lives beside the names registrar: ~/arkswap is a coinosv3 checkout on cs.
-services:
- blossom:
- image: oven/bun:1
- container_name: blossom
- working_dir: /app
- command: bun blossom/server.js
- environment:
- BLOSSOM_DATA: /data
- BLOSSOM_PORT: "8796"
- BLOSSOM_HOST: blossom.coinos.io
- volumes:
- - /home/adam/arkswap:/app
- - /home/adam/arkswap/data-blossom:/data
- ports:
- - 127.0.0.1:8796:8796
- networks: [net]
- restart: unless-stopped
-networks:
- net:
- external: true
diff --git a/blossom/server.js b/blossom/server.js
deleted file mode 100644
index 9d3d631..0000000
--- a/blossom/server.js
+++ /dev/null
@@ -1,281 +0,0 @@
-// blossom.coinos.io — a Blossom (BUD-01/02/06/12) blob server that stores
-// ONE kind of thing: encrypted wallet-sync envelopes. The wallet keeps its
-// cross-device state in nostr replaceable events, but a deep Ark coin set
-// outgrows a relay's event cap; those domains go here as an encrypted blob
-// and the nostr event carries only its hash. Any Blossom server can hold the
-// blob (content-addressed), so users can self-host or pick another provider.
-//
-// Abuse policy — this must never become a file host:
-// * uploads, listing and deletion need a signed kind-24242 event (BUD-11)
-// * a blob is stored only if it starts with the wallet's envelope magic
-// ("CSB1" + 24-byte nonce): everything else is 415, so images, video and
-// arbitrary files are refused at the door
-// * blobs are always served as application/octet-stream, as an attachment,
-// nosniff — nothing here renders in a browser or embeds anywhere
-// * per-blob size cap, per-pubkey quota (bytes and count, oldest evicted),
-// per-IP/pubkey rate limits, a global disk cap, and unused-blob expiry
-//
-// Run: bun blossom/server.js (env: BLOSSOM_DATA, BLOSSOM_PORT, BLOSSOM_HOST,
-// BLOSSOM_MAX_BLOB, BLOSSOM_QUOTA_BYTES, BLOSSOM_QUOTA_COUNT,
-// BLOSSOM_DISK_CAP, BLOSSOM_TTL_DAYS)
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, statSync } from 'node:fs';
-import { join } from 'node:path';
-import { createHash } from 'node:crypto';
-import { verifyEvent } from 'nostr-tools/pure';
-
-const DATA = process.env.BLOSSOM_DATA || join(import.meta.dir, 'data');
-const PORT = Number(process.env.BLOSSOM_PORT || 8796);
-const HOST = process.env.BLOSSOM_HOST || 'blossom.coinos.io';
-const PUBLIC_BASE = process.env.BLOSSOM_PUBLIC_BASE || `https://${HOST}`;
-const MAX_BLOB = Number(process.env.BLOSSOM_MAX_BLOB || 8 * 1024 * 1024); // 8 MB per blob
-const QUOTA_BYTES = Number(process.env.BLOSSOM_QUOTA_BYTES || 32 * 1024 * 1024); // 32 MB per pubkey
-const QUOTA_COUNT = Number(process.env.BLOSSOM_QUOTA_COUNT || 24); // blobs per pubkey
-const DISK_CAP = Number(process.env.BLOSSOM_DISK_CAP || 40 * 1024 * 1024 * 1024); // 40 GB total
-const TTL_DAYS = Number(process.env.BLOSSOM_TTL_DAYS || 365); // unused-blob expiry
-const MAGIC = new TextEncoder().encode('CSB1');
-const ENVELOPE_MIN = MAGIC.length + 24 + 16; // magic + nonce + poly1305 tag
-
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// ---------------------------------------------------------------------------
-// storage: blobs/<sha256> on disk, a small JSON index for ownership + times
-// ---------------------------------------------------------------------------
-const BLOBS = join(DATA, 'blobs');
-const INDEX = join(DATA, 'index.json');
-mkdirSync(BLOBS, { recursive: true });
-let index = (() => {
- try { return JSON.parse(readFileSync(INDEX, 'utf8')); } catch { return { blobs: {} }; }
-})();
-// index.blobs[sha] = { size, uploaded, touched, owners: { [pubkey]: uploadedAt } }
-let persistTimer = null;
-function persist() {
- clearTimeout(persistTimer);
- persistTimer = setTimeout(() => {
- const tmp = INDEX + '.tmp';
- writeFileSync(tmp, JSON.stringify(index));
- renameSync(tmp, INDEX);
- }, 200);
-}
-const blobPath = (sha) => join(BLOBS, sha);
-const totalBytes = () => Object.values(index.blobs).reduce((n, b) => n + b.size, 0);
-const ownedBy = (pk) => Object.entries(index.blobs).filter(([, b]) => b.owners[pk]).map(([sha, b]) => ({ sha, ...b }));
-
-function descriptor(sha, b) {
- return { url: `${PUBLIC_BASE}/${sha}`, sha256: sha, size: b.size, type: 'application/octet-stream', uploaded: Math.floor(b.uploaded / 1000) };
-}
-function dropBlob(sha) {
- delete index.blobs[sha];
- try { unlinkSync(blobPath(sha)); } catch {}
-}
-// Remove a pubkey's claim; the file goes when nobody claims it.
-function disown(sha, pk) {
- const b = index.blobs[sha];
- if (!b) return;
- delete b.owners[pk];
- if (!Object.keys(b.owners).length) dropBlob(sha);
-}
-// Quota: a pubkey keeps at most QUOTA_COUNT blobs / QUOTA_BYTES — the OLDEST
-// go first, so a client that forgets to delete superseded snapshots keeps
-// working (the newest state is what sync needs).
-function enforceQuota(pk, incomingSize) {
- const mine = ownedBy(pk).sort((a, b) => a.owners[pk] - b.owners[pk]);
- let bytes = mine.reduce((n, b) => n + b.size, 0) + incomingSize;
- let count = mine.length + 1;
- for (const b of mine) {
- if (count <= QUOTA_COUNT && bytes <= QUOTA_BYTES) break;
- disown(b.sha, pk);
- bytes -= b.size; count--;
- }
- return count <= QUOTA_COUNT && bytes <= QUOTA_BYTES;
-}
-// Blobs nobody fetched or re-uploaded in TTL_DAYS are abandoned wallets'
-// snapshots (or a client that moved on) — sweep them.
-function sweep() {
- const cutoff = Date.now() - TTL_DAYS * 86400_000;
- let n = 0;
- for (const [sha, b] of Object.entries(index.blobs)) if ((b.touched || b.uploaded) < cutoff) { dropBlob(sha); n++; }
- if (n) { log('swept', n, 'expired blobs'); persist(); }
-}
-setInterval(sweep, 6 * 3600_000);
-sweep();
-
-// ---------------------------------------------------------------------------
-// auth (BUD-11): kind 24242, t=verb, expiration in the future, x=sha when the
-// endpoint names a blob, optional server tags naming us
-// ---------------------------------------------------------------------------
-function checkAuth(req, verb, sha = null) {
- const h = req.headers.get('authorization') || '';
- const m = h.match(/^Nostr\s+(.+)$/i);
- if (!m) return { error: 'missing Nostr authorization' };
- let evt;
- try { evt = JSON.parse(Buffer.from(m[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; }
- if (!evt || evt.kind !== 24242) return { error: 'auth must be kind 24242' };
- const now = Math.floor(Date.now() / 1000);
- if (!(evt.created_at <= now + 60)) return { error: 'auth created in the future' };
- const tags = Array.isArray(evt.tags) ? evt.tags : [];
- const tag = (k) => tags.filter((x) => x[0] === k).map((x) => x[1]);
- const exp = Number(tag('expiration')[0]);
- if (!exp || exp <= now) return { error: 'auth expired' };
- if (!tag('t').includes(verb)) return { error: `auth is not for ${verb}` };
- const servers = tag('server');
- if (servers.length && !servers.some((s) => String(s).toLowerCase() === HOST)) return { error: 'auth is for another server' };
- if (sha && !tag('x').includes(sha)) return { error: 'auth does not name this blob' };
- if (!verifyEvent(evt)) return { error: 'bad signature' };
- return { pubkey: evt.pubkey };
-}
-
-// ---------------------------------------------------------------------------
-// rate limits: per IP and per pubkey, sliding minute
-// ---------------------------------------------------------------------------
-const rate = new Map();
-function rateOk(key, limit) {
- const now = Date.now();
- const arr = (rate.get(key) || []).filter((t) => t > now - 60_000);
- if (arr.length >= limit) return false;
- arr.push(now);
- rate.set(key, arr);
- if (rate.size > 5000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); }
- return true;
-}
-
-// ---------------------------------------------------------------------------
-// http
-// ---------------------------------------------------------------------------
-const CORS = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-SHA-256, X-Content-Length, X-Content-Type, *',
- 'Access-Control-Allow-Methods': 'GET, HEAD, PUT, DELETE, OPTIONS',
- 'Access-Control-Expose-Headers': 'X-Reason, Content-Length',
- 'Access-Control-Max-Age': '86400',
-};
-const json = (obj, status = 200, extra = {}) =>
- new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS, ...extra } });
-const fail = (status, reason) => json({ message: reason }, status, { 'X-Reason': reason });
-const SHA_RE = /^\/([0-9a-f]{64})(\.[a-z0-9]+)?$/;
-
-// Envelope check: our client's sealed snapshot, nothing else.
-function envelopeOk(head, size) {
- if (size < ENVELOPE_MIN || size > MAX_BLOB) return false;
- for (let i = 0; i < MAGIC.length; i++) if (head[i] !== MAGIC[i]) return false;
- return true;
-}
-// BUD-06 style policy check shared by HEAD /upload and PUT /upload.
-function uploadPolicy(size, head, pk) {
- if (!Number.isFinite(size) || size <= 0) return fail(411, 'X-Content-Length required');
- if (size > MAX_BLOB) return fail(413, `blob exceeds ${MAX_BLOB} bytes`);
- if (head && !envelopeOk(head, size)) return fail(415, 'only coinos wallet sync envelopes are stored here');
- if (totalBytes() + size > DISK_CAP) return fail(503, 'storage full');
- if (pk && !enforceQuota(pk, size)) return fail(413, 'per-key quota exceeded');
- return null;
-}
-
-Bun.serve({
- port: PORT,
- maxRequestBodySize: MAX_BLOB + 1024,
- async fetch(req) {
- const url = new URL(req.url);
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS });
-
- if (url.pathname === '/' && req.method === 'GET') {
- return json({ name: HOST, about: 'coinos wallet sync storage (Blossom). Stores encrypted wallet snapshots only.',
- maxBlob: MAX_BLOB, quotaBytes: QUOTA_BYTES, quotaCount: QUOTA_COUNT, blobs: Object.keys(index.blobs).length });
- }
-
- // ---- upload requirements (BUD-06) ----
- if (url.pathname === '/upload' && req.method === 'HEAD') {
- if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited');
- const sha = (req.headers.get('x-sha-256') || '').toLowerCase();
- const a = checkAuth(req, 'upload', /^[0-9a-f]{64}$/.test(sha) ? sha : null);
- if (a.error) return fail(401, a.error);
- const size = Number(req.headers.get('x-content-length'));
- if (!Number.isFinite(size) || size <= 0) return fail(411, 'X-Content-Length required');
- if (size < ENVELOPE_MIN) return fail(415, 'only coinos wallet sync envelopes are stored here');
- const type = (req.headers.get('x-content-type') || '').split(';')[0].trim();
- if (type && type !== 'application/octet-stream') return fail(415, 'only application/octet-stream sync envelopes are stored here');
- // quota is not checked here: PUT evicts the key's oldest blob to make room
- const bad = uploadPolicy(size, null, null);
- if (bad) return bad;
- return new Response(null, { status: 200, headers: CORS });
- }
-
- // ---- upload (BUD-02) ----
- if (url.pathname === '/upload' && req.method === 'PUT') {
- if (!rateOk('ip:' + ip, 60)) return fail(429, 'rate limited');
- const bytes = new Uint8Array(await req.arrayBuffer());
- const sha = createHash('sha256').update(bytes).digest('hex');
- const a = checkAuth(req, 'upload', sha);
- if (a.error) return fail(401, a.error);
- if (!rateOk('pk:' + a.pubkey, 30)) return fail(429, 'rate limited');
- const want = (req.headers.get('x-sha-256') || '').toLowerCase();
- if (want && want !== sha) return fail(409, 'X-SHA-256 does not match the body');
- const type = (req.headers.get('content-type') || '').split(';')[0].trim();
- if (type && type !== 'application/octet-stream') return fail(415, 'only application/octet-stream sync envelopes are stored here');
- const existing = index.blobs[sha];
- const bad = uploadPolicy(bytes.length, bytes.subarray(0, MAGIC.length), existing?.owners[a.pubkey] ? null : a.pubkey);
- if (bad) return bad;
- const now = Date.now();
- if (!existing) {
- writeFileSync(blobPath(sha), bytes);
- index.blobs[sha] = { size: bytes.length, uploaded: now, touched: now, owners: { [a.pubkey]: now } };
- } else {
- existing.touched = now;
- existing.owners[a.pubkey] = existing.owners[a.pubkey] || now;
- }
- persist();
- log('upload', sha.slice(0, 12), bytes.length, 'b', a.pubkey.slice(0, 8), existing ? '(exists)' : '');
- return json(descriptor(sha, index.blobs[sha]), existing ? 200 : 201);
- }
-
- // ---- list (BUD-12): only the owner sees their own blobs ----
- const lm = url.pathname.match(/^\/list\/([0-9a-f]{64})$/);
- if (lm && req.method === 'GET') {
- if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited');
- const a = checkAuth(req, 'list');
- if (a.error) return fail(401, a.error);
- if (a.pubkey !== lm[1]) return fail(403, 'you can only list your own blobs');
- const since = Number(url.searchParams.get('since')) || 0, until = Number(url.searchParams.get('until')) || Infinity;
- const list = ownedBy(a.pubkey)
- .map((b) => descriptor(b.sha, b))
- .filter((d) => d.uploaded >= since && d.uploaded <= until)
- .sort((x, y) => y.uploaded - x.uploaded);
- return json(list);
- }
-
- // ---- fetch / delete a blob ----
- const bm = url.pathname.match(SHA_RE);
- if (bm) {
- const sha = bm[1];
- const b = index.blobs[sha];
- if (req.method === 'DELETE') {
- if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited');
- const a = checkAuth(req, 'delete', sha);
- if (a.error) return fail(401, a.error);
- if (!b) return fail(404, 'no such blob');
- if (!b.owners[a.pubkey]) return fail(403, 'not your blob');
- disown(sha, a.pubkey);
- persist();
- log('delete', sha.slice(0, 12), a.pubkey.slice(0, 8));
- return new Response(null, { status: 204, headers: CORS });
- }
- if (req.method === 'GET' || req.method === 'HEAD') {
- if (!rateOk('ip:' + ip, 300)) return fail(429, 'rate limited');
- if (!b || !existsSync(blobPath(sha))) return fail(404, 'no such blob');
- b.touched = Date.now(); persist();
- const headers = {
- ...CORS,
- 'content-type': 'application/octet-stream',
- 'content-length': String(b.size),
- 'content-disposition': `attachment; filename="${sha}.bin"`,
- 'x-content-type-options': 'nosniff',
- 'cache-control': 'private, max-age=31536000, immutable',
- };
- if (req.method === 'HEAD') return new Response(null, { status: 200, headers });
- return new Response(Bun.file(blobPath(sha)), { status: 200, headers });
- }
- }
- return fail(404, 'not found');
- },
-});
-log(`blossom ${HOST} on :${PORT} — ${Object.keys(index.blobs).length} blobs, ${(totalBytes() / 1e6).toFixed(1)} MB, data ${DATA}`);
diff --git a/bridge/.gitignore b/bridge/.gitignore
deleted file mode 100644
index 45bbf0e..0000000
--- a/bridge/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/
-config.json
diff --git a/bridge/config.example.json b/bridge/config.example.json
deleted file mode 100644
index fee8ad7..0000000
--- a/bridge/config.example.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "port": 8790,
- "network": "regtest",
- "ark": "http://127.0.0.1:3535",
- "esplora": "http://127.0.0.1:30002",
- "mnemonic": "REPLACE with a 12/24-word mnemonic — this wallet holds bridge float",
- "stateFile": "./data/bridge.json",
- "ln": { "kind": "docker", "container": "clc", "network": "regtest" },
- "fee": { "baseSat": 0, "ppm": 1000, "minSat": 1 },
- "limits": { "minSat": 1, "maxSat": 1000000, "htlcBlocks": 144 },
- "tokens": []
-}
diff --git a/bridge/ln.js b/bridge/ln.js
deleted file mode 100644
index cf2eab4..0000000
--- a/bridge/ln.js
+++ /dev/null
@@ -1,116 +0,0 @@
-// Lightning backend for the swap bridge: pay a bolt11 and report the
-// preimage. Deliberately tiny — the bridge only ever needs to *send* over
-// Lightning (an ark->LN swap), because an ASP's LN *receive* is already free.
-//
-// Two adapters, both CLN, both using named parameters:
-// 'socket' — lightning-rpc unix socket (production: mount the socket)
-// 'docker' — `docker exec <container> lightning-cli` (regtest convenience)
-
-import { execFile } from 'node:child_process';
-import { createConnection } from 'node:net';
-
-const run = (cmd, args) => new Promise((resolve, reject) => {
- execFile(cmd, args, { maxBuffer: 8 << 20 }, (err, stdout, stderr) => {
- if (err) return reject(new Error((stderr || err.message).trim().slice(0, 400)));
- resolve(stdout);
- });
-});
-
-// CLN prints progress lines before the JSON body on some commands.
-const parseJson = (s) => {
- const i = s.indexOf('{');
- if (i < 0) throw new Error('no JSON in CLN response: ' + s.slice(0, 200));
- return JSON.parse(s.slice(i));
-};
-
-function dockerAdapter({ container, network }) {
- return {
- async call(method, params = {}) {
- const args = ['exec', container, 'lightning-cli', `--${network}`, method,
- ...Object.entries(params).map(([k, v]) => `${k}=${v}`)];
- return parseJson(await run('docker', args));
- },
- };
-}
-
-function socketAdapter({ path }) {
- let id = 0;
- return {
- call(method, params = {}) {
- return new Promise((resolve, reject) => {
- const sock = createConnection(path);
- let buf = '';
- sock.on('error', reject);
- sock.on('connect', () => sock.write(JSON.stringify({
- jsonrpc: '2.0', id: ++id, method, params,
- }) + '\n'));
- sock.on('data', (d) => {
- buf += d;
- let nl;
- while ((nl = buf.indexOf('\n')) >= 0) {
- const line = buf.slice(0, nl).trim();
- buf = buf.slice(nl + 1);
- if (!line) continue;
- let msg;
- try { msg = JSON.parse(line); } catch { continue; }
- sock.end();
- if (msg.error) return reject(new Error(JSON.stringify(msg.error).slice(0, 400)));
- return resolve(msg.result);
- }
- });
- });
- },
- };
-}
-
-export function lnBackend(cfg) {
- const rpc = cfg.kind === 'docker' ? dockerAdapter(cfg) : socketAdapter(cfg);
-
- return {
- // Raw RPC passthrough — the names registrar drives offers/waitanyinvoice.
- call: (method, params) => rpc.call(method, params),
-
- async info() {
- const r = await rpc.call('getinfo');
- return { id: r.id, alias: r.alias, blockheight: r.blockheight, network: r.network };
- },
-
- // Spendable outbound capacity, so the bridge can refuse a swap it can't
- // route rather than taking an HTLC it will only have to refund.
- async outboundSat() {
- const r = await rpc.call('listpeerchannels');
- return (r.channels || [])
- .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected)
- .reduce((n, c) => n + Math.floor((c.spendable_msat ?? 0) / 1000), 0);
- },
-
- // Pay a bolt11. Resolves { preimage, feeSat }; throws otherwise.
- // maxfee caps routing spend so a swap can't cost more than we charged.
- async pay(bolt11, { maxfeeSat, retryFor = 60 } = {}) {
- const params = { bolt11, retry_for: retryFor };
- if (maxfeeSat != null) params.maxfee = `${Math.max(0, Math.floor(maxfeeSat))}sat`;
- const r = await rpc.call('pay', params);
- if (r.status && r.status !== 'complete') throw new Error(`payment not complete: ${r.status}`);
- if (!r.payment_preimage) throw new Error('payment returned no preimage');
- return {
- preimage: r.payment_preimage,
- feeSat: Math.ceil((((r.amount_sent_msat ?? 0) - (r.amount_msat ?? 0)) || 0) / 1000),
- };
- },
-
- // Has this payment hash already been paid by us? Checked before paying so
- // a crashed-and-resumed bridge never double-pays.
- async paidPreimage(paymentHash) {
- const r = await rpc.call('listpays', { payment_hash: paymentHash }).catch(() => null);
- const done = (r?.pays || []).find((p) => p.status === 'complete');
- return done?.preimage || null;
- },
-
- // Is a payment still in flight? A pending pay must not be retried and
- // must not be refunded — its preimage may still arrive.
- async isPending(paymentHash) {
- const r = await rpc.call('listpays', { payment_hash: paymentHash }).catch(() => null);
- return (r?.pays || []).some((p) => p.status === 'pending');
- },
- };
-}
diff --git a/bridge/server.js b/bridge/server.js
deleted file mode 100644
index 6a5c04e..0000000
--- a/bridge/server.js
+++ /dev/null
@@ -1,373 +0,0 @@
-// Trustless Ark -> Lightning swap bridge.
-//
-// A third-party swap provider: users pay bolt11 invoices out of their Ark
-// balance without the ASP being the counterparty and without trusting us with
-// custody. Requires an ASP supporting VtxoPolicy::Htlc (see the upstream
-// proposal in bark's docs/third-party-htlc.md).
-//
-// Why only ark -> LN: an ASP's lightning *receive* is already free (0 base,
-// 0 ppm on mainnet today). The expensive direction is *send*, which has a
-// 20 sat floor — 80% of a 25 sat zap. That's the leg worth undercutting; the
-// receive leg stays on the ASP's native path.
-//
-// The swap:
-// 1. GET /quote?invoice=... -> amount, fee, our claim pubkey, expiry
-// 2. POST /swap {invoice, htlcVtxo}
-// the user has locked funds in an HTLC that only we can claim, and
-// only by revealing the invoice preimage. We validate it, pay the
-// invoice, then claim with the preimage.
-// 3. POST /refund {paymentHash}
-// we couldn't pay: we cosign the HTLC back to the user's refund key.
-// The ASP constrains such a spend to pay only the refunder, so this
-// endpoint cannot be turned into a redirect.
-//
-// What the user risks: nothing. If we vanish between lock and claim, the HTLC
-// expires and they exit through the refund leaf. We cannot take the funds
-// without the preimage, which we only have if we paid their invoice.
-//
-// What WE risk: paying an invoice and then failing to claim. Mitigated by
-// validating before paying, claiming immediately after, and retrying on
-// resume — we hold the preimage, so the claim stays available (worst case
-// through an on-chain exit of the HTLC leaf).
-
-import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { hex } from '@scure/base';
-import { sha256 } from '@noble/hashes/sha256';
-import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync } from '@scure/bip39';
-
-import { ArkManager } from '../src/ark/manager.js';
-import { decodeVtxo } from '../src/ark/proto.js';
-import { validateVtxo } from '../src/ark/validate.js';
-import { decodeBolt11 } from '../src/ark/lightning.js';
-import { lnBackend } from './ln.js';
-
-// ---------------------------------------------------------------------------
-// config
-// ---------------------------------------------------------------------------
-
-const CONFIG_PATH = process.env.BRIDGE_CONFIG || join(import.meta.dir, 'config.json');
-const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
-// {
-// "port": 8790, "network": "regtest",
-// "ark": "http://127.0.0.1:3535", "esplora": "http://127.0.0.1:30002",
-// "mnemonic": "...", "stateFile": "./data/bridge.json",
-// "ln": { "kind": "docker", "container": "clc", "network": "regtest" },
-// "fee": { "baseSat": 0, "ppm": 1000, "minSat": 1 },
-// "limits": { "minSat": 1, "maxSat": 1000000, "htlcBlocks": 144 },
-// "tokens": ["..."] // omit or empty to run open
-// }
-
-const FEE = { baseSat: 0, ppm: 1000, minSat: 1, ...(cfg.fee || {}) };
-const LIMITS = { minSat: 1, maxSat: 1_000_000, htlcBlocks: 144, ...(cfg.limits || {}) };
-const TOKENS = new Set(cfg.tokens || []);
-const STATE_PATH = cfg.stateFile || join(import.meta.dir, 'data', 'bridge.json');
-
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// The fee we charge for a swap. Deliberately simple and cheap: our real cost
-// is the routing fee plus an amortized slice of rebalancing.
-const swapFee = (amountSat) =>
- Math.max(FEE.minSat, FEE.baseSat + Math.ceil((amountSat * FEE.ppm) / 1_000_000));
-
-// ---------------------------------------------------------------------------
-// state (swap records, crash-safe via atomic replace)
-// ---------------------------------------------------------------------------
-
-function loadState() {
- try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); } catch { return { swaps: {} }; }
-}
-let state = loadState();
-function saveState() {
- mkdirSync(dirname(STATE_PATH), { recursive: true });
- const tmp = STATE_PATH + '.tmp';
- writeFileSync(tmp, JSON.stringify(state, null, 1));
- renameSync(tmp, STATE_PATH); // atomic
-}
-
-// ---------------------------------------------------------------------------
-// wallets
-// ---------------------------------------------------------------------------
-
-const account = HDKey.fromMasterSeed(mnemonicToSeedSync(cfg.mnemonic)).derive("m/86'/0'/9'");
-const arkStore = {
- load: () => state.ark || null,
- save: (s) => { state.ark = s; saveState(); },
-};
-const ark = await new ArkManager({
- account, storage: arkStore, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: cfg.network,
-}).init();
-const ln = lnBackend(cfg.ln);
-
-// Our HTLC claim key. Fixed index so it survives restarts and users can pin it.
-const CLAIM_INDEX = 0;
-const claimPubkey = hex.encode(ark._key(CLAIM_INDEX).pubkey);
-
-// ---------------------------------------------------------------------------
-// swap engine
-// ---------------------------------------------------------------------------
-
-const swap = (hash) => state.swaps[hash];
-function putSwap(hash, patch) {
- state.swaps[hash] = { ...(state.swaps[hash] || {}), ...patch, updated: Date.now() };
- saveState();
- return state.swaps[hash];
-}
-
-// Validate a user's HTLC lock. Everything here must pass BEFORE we spend a
-// satoshi on Lightning — this is the only thing standing between us and
-// paying an invoice against a worthless VTXO.
-async function validateLock({ htlcVtxoBytes, invoice, dec }) {
- const bytes = hex.decode(htlcVtxoBytes);
- const v = decodeVtxo(bytes);
-
- if (v.policy.type !== 'htlc') throw new Error('vtxo is not an HTLC policy');
- if (v.policy.claimPubkey !== claimPubkey) throw new Error('HTLC is not claimable by this bridge');
- if (v.policy.paymentHash !== dec.paymentHash) throw new Error('HTLC payment hash does not match the invoice');
-
- const amountSat = dec.amountSat;
- const fee = swapFee(amountSat);
- if (v.amountSat < amountSat + fee) {
- throw new Error(`HTLC underfunded: ${v.amountSat} < ${amountSat} + ${fee} fee`);
- }
-
- // Expiry must leave us enough room to pay and claim. Too short and we could
- // pay an invoice and then watch the user refund it out from under us.
- const tip = await ark.chain.tipHeight();
- const left = v.policy.htlcExpiry - tip;
- if (left < Math.floor(LIMITS.htlcBlocks / 2)) {
- throw new Error(`HTLC expires too soon (${left} blocks left)`);
- }
-
- // Full chain validation against the ASP + the chain: this proves the VTXO
- // exists, is properly cosigned and is anchored on-chain.
- await validateVtxo(v, {
- serverPubkey: ark.serverPub, chain: ark.chain, allowPolicies: ['htlc'],
- });
-
- return { vtxo: v, amountSat, fee };
-}
-
-// Drive one swap to a terminal state. Idempotent and safe to re-enter: each
-// step checks reality (has the invoice been paid? is the HTLC already spent?)
-// rather than trusting the stored step alone.
-async function driveSwap(hash) {
- const s = swap(hash);
- if (!s || ['done', 'refunded', 'failed'].includes(s.step)) return s;
-
- // Never pay twice: ask the node what it actually did.
- if (s.step === 'validated' || s.step === 'paying') {
- const already = await ln.paidPreimage(hash);
- if (already) putSwap(hash, { step: 'paid', preimage: already });
- else if (await ln.isPending(hash)) return swap(hash); // in flight; come back later
- }
-
- if (swap(hash).step === 'validated') {
- putSwap(hash, { step: 'paying' });
- try {
- const { preimage, feeSat } = await ln.pay(s.invoice, { maxfeeSat: s.fee });
- if (hex.encode(sha256(hex.decode(preimage))) !== hash) {
- throw new Error('node returned a preimage that does not match the payment hash');
- }
- putSwap(hash, { step: 'paid', preimage, routingFeeSat: feeSat });
- log(`paid ${hash.slice(0, 12)} amount=${s.amountSat} routing=${feeSat} charged=${s.fee}`);
- } catch (e) {
- // The payment failed; the user's money is untouched. Offer the refund.
- log(`pay failed ${hash.slice(0, 12)}: ${e.message}`);
- putSwap(hash, { step: 'refundable', error: e.message });
- return swap(hash);
- }
- }
-
- if (swap(hash).step === 'paid') {
- // We hold the preimage, so this claim can always be made — retry until it
- // lands (and in the worst case it is claimable on-chain via the leaf).
- try {
- await ark.htlcClaim({
- htlcVtxoBytes: swap(hash).htlcVtxo,
- claimKeyIndex: CLAIM_INDEX,
- preimage: swap(hash).preimage,
- });
- putSwap(hash, { step: 'done' });
- log(`claimed ${hash.slice(0, 12)} — swap complete`);
- } catch (e) {
- if (/already spent|not spendable/i.test(e.message)) {
- putSwap(hash, { step: 'done', note: 'htlc already spent' });
- } else {
- putSwap(hash, { claimError: e.message });
- log(`claim failed ${hash.slice(0, 12)} (will retry): ${e.message}`);
- }
- }
- }
-
- return swap(hash);
-}
-
-// Resume anything in flight, then keep nudging.
-async function resumeAll() {
- for (const hash of Object.keys(state.swaps)) {
- await driveSwap(hash).catch((e) => log('resume error', hash.slice(0, 12), e.message));
- }
-}
-
-// ---------------------------------------------------------------------------
-// HTTP API
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type,authorization',
- 'access-control-allow-methods': 'GET,POST,OPTIONS',
- },
-});
-const fail = (msg, status = 400) => json({ error: msg }, status);
-
-function authed(req) {
- if (!TOKENS.size) return true; // open mode
- const h = req.headers.get('authorization') || '';
- return TOKENS.has(h.replace(/^Bearer\s+/i, '').trim());
-}
-
-const server = Bun.serve({
- port: cfg.port || 8790,
- async fetch(req) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
-
- try {
- // Public: what this bridge is and what it charges.
- if (url.pathname === '/info') {
- return json({
- network: cfg.network,
- ark: cfg.ark,
- claimPubkey,
- fee: FEE,
- limits: LIMITS,
- requiresToken: TOKENS.size > 0,
- outboundSat: await ln.outboundSat().catch(() => null),
- });
- }
-
- if (url.pathname === '/quote') {
- const invoice = url.searchParams.get('invoice');
- if (!invoice) return fail('missing invoice');
- let dec;
- try { dec = decodeBolt11(invoice); } catch (e) { return fail('bad invoice: ' + e.message); }
- if (!dec.amountSat) return fail('invoice has no amount');
- if (dec.amountSat < LIMITS.minSat || dec.amountSat > LIMITS.maxSat) {
- return fail(`amount out of range (${LIMITS.minSat}–${LIMITS.maxSat} sat)`);
- }
- if (Date.now() > dec.expiresAt) return fail('invoice expired');
- const outbound = await ln.outboundSat().catch(() => 0);
- if (outbound < dec.amountSat) return fail('insufficient outbound liquidity', 503);
- const tip = await ark.chain.tipHeight();
- return json({
- amountSat: dec.amountSat,
- feeSat: swapFee(dec.amountSat),
- totalSat: dec.amountSat + swapFee(dec.amountSat),
- paymentHash: dec.paymentHash,
- claimPubkey,
- htlcExpiry: tip + LIMITS.htlcBlocks,
- });
- }
-
- if (url.pathname === '/swap' && req.method === 'POST') {
- if (!authed(req)) return fail('unauthorized', 401);
- const body = await req.json();
- const { invoice, htlcVtxo } = body || {};
- if (!invoice || !htlcVtxo) return fail('missing invoice or htlcVtxo');
-
- let dec;
- try { dec = decodeBolt11(invoice); } catch (e) { return fail('bad invoice: ' + e.message); }
- const hash = dec.paymentHash;
-
- const existing = swap(hash);
- if (existing) {
- // idempotent: same swap resubmitted (client retry / crash recovery)
- return json(publicSwap(hash, await driveSwap(hash)));
- }
- if (!dec.amountSat) return fail('invoice has no amount');
- if (Date.now() > dec.expiresAt) return fail('invoice expired');
-
- let checked;
- try {
- checked = await validateLock({ htlcVtxoBytes: htlcVtxo, invoice, dec });
- } catch (e) {
- return fail('invalid HTLC: ' + e.message);
- }
-
- putSwap(hash, {
- step: 'validated', invoice, htlcVtxo,
- amountSat: checked.amountSat, fee: checked.fee,
- htlcAmountSat: checked.vtxo.amountSat,
- created: Date.now(),
- });
- log(`accepted ${hash.slice(0, 12)} amount=${checked.amountSat} fee=${checked.fee}`);
-
- return json(publicSwap(hash, await driveSwap(hash)));
- }
-
- // Poll a swap (or nudge a stuck one forward).
- if (url.pathname === '/swap' && req.method === 'GET') {
- const hash = url.searchParams.get('paymentHash');
- if (!hash || !swap(hash)) return fail('unknown swap', 404);
- return json(publicSwap(hash, await driveSwap(hash)));
- }
-
- // Give up on a swap we couldn't pay: cosign the HTLC back to the user.
- // Safe to expose — the ASP only cosigns a preimage-less HTLC spend when
- // every output pays the refund pubkey, so we cannot redirect it.
- if (url.pathname === '/refund' && req.method === 'POST') {
- const body = await req.json();
- const hash = body?.paymentHash;
- const s = hash && swap(hash);
- if (!s) return fail('unknown swap', 404);
- if (s.step === 'done') return fail('swap already completed');
- if (s.step === 'paid') return fail('invoice was paid; the swap will complete');
- if (await ln.isPending(hash)) return fail('payment still in flight, try again shortly', 409);
- if (s.step === 'refunded') return json({ paymentHash: hash, step: 'refunded', refundVtxos: s.refundVtxos });
-
- const refundVtxos = await ark.htlcCosignRefund({
- htlcVtxoBytes: s.htlcVtxo, claimKeyIndex: CLAIM_INDEX,
- });
- putSwap(hash, { step: 'refunded', refundVtxos });
- log(`refunded ${hash.slice(0, 12)}`);
- return json({ paymentHash: hash, step: 'refunded', refundVtxos });
- }
-
- return fail('not found', 404);
- } catch (e) {
- log('handler error:', e.stack || e.message);
- return fail('internal error: ' + e.message, 500);
- }
- },
-});
-
-// What we tell the client — never the raw stored record.
-function publicSwap(hash, s = swap(hash)) {
- return {
- paymentHash: hash,
- step: s.step,
- amountSat: s.amountSat,
- feeSat: s.fee,
- preimage: s.preimage || null, // the user's proof of payment
- error: s.error || s.claimError || null,
- refundable: s.step === 'refundable',
- };
-}
-
-log(`bridge listening on :${server.port}`);
-log(` network=${cfg.network} ark=${cfg.ark}`);
-log(` claim pubkey ${claimPubkey}`);
-log(` ark balance ${ark.balance().spendableSat} sat`);
-log(` fee: max(${FEE.minSat}, ${FEE.baseSat} + ${FEE.ppm}ppm)`);
-try { log(` ln node ${(await ln.info()).id.slice(0, 16)}… outbound=${await ln.outboundSat()} sat`); }
-catch (e) { log(' WARNING: lightning backend unreachable:', e.message); }
-
-await resumeAll();
-setInterval(() => { resumeAll().catch(() => {}); }, 15_000);
-setInterval(() => { ark.sync().catch(() => {}); }, 30_000);
diff --git a/names/compose-libretranslate.yml b/names/compose-libretranslate.yml
deleted file mode 100644
index 80ed9c6..0000000
--- a/names/compose-libretranslate.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-# LibreTranslate for post translation (coinos v3). Reached only by the
-# names registrar over the docker network (POST /translate there); no port
-# on the host, no web UI. Models live in the lt-data volume.
-services:
- libretranslate:
- image: libretranslate/libretranslate:v1.9.6
- container_name: libretranslate
- environment:
- LT_LOAD_ONLY: en,es,fr,de,pt,id,tr,vi,ru,ar,ur,hi,bn,ja,ko,zh,it,nl,pl,uk,fa,he,th,sv,cs,el,ro,hu,fi,da,nb,ca
- LT_DISABLE_WEB_UI: "true"
- LT_CHAR_LIMIT: "5000"
- LT_THREADS: "4"
- LT_UPDATE_MODELS: "true"
- volumes:
- - lt-data:/home/libretranslate/.local
- networks: [net]
- deploy:
- resources:
- limits:
- memory: 8g
- restart: unless-stopped
-volumes:
- lt-data:
-networks:
- net:
- external: true
diff --git a/names/ownership.js b/names/ownership.js
deleted file mode 100644
index 19ae4ef..0000000
--- a/names/ownership.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// A migration grant names the identity that owns the address. Its manager
-// may submit the registration without becoming that identity.
-export function registrationIdentity({ existing, signer, manager, grant, granted }) {
- const pubkey = existing?.pubkey || (granted ? grant.pubkey : signer);
- const nominated = /^[0-9a-f]{64}$/.test(manager || '') && manager !== pubkey;
- return {
- pubkey,
- manager: nominated ? manager : existing?.manager || (granted ? grant.manager : undefined),
- };
-}
diff --git a/names/preview.js b/names/preview.js
deleted file mode 100644
index b9af975..0000000
--- a/names/preview.js
+++ /dev/null
@@ -1,133 +0,0 @@
-// Link previews for the wallet's feed: the Open Graph card a page describes
-// for itself (title, one line of description, a picture, the site's name).
-// A browser can't read another site's HTML, so the registrar fetches it
-// once on the reader's behalf and hands back only what a card needs.
-//
-// The server sees more than the internet does (containers, the LAN), so a
-// URL is fetched only after its host resolves to public addresses — and
-// again after every redirect. Bodies are capped, and only HTML is read.
-import { promises as dns } from 'node:dns';
-
-const MAX_BODY = 512 * 1024;
-const TIMEOUT_MS = 7000;
-const MAX_HOPS = 4;
-const UA = 'Mozilla/5.0 (compatible; coinos-link-preview/1.0; +https://coinos.io)';
-
-const isPrivate4 = (ip) => {
- const p = ip.split('.').map(Number);
- return p[0] === 10 || p[0] === 127 || p[0] === 0
- || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
- || (p[0] === 192 && p[1] === 168)
- || (p[0] === 169 && p[1] === 254)
- || (p[0] === 100 && p[1] >= 64 && p[1] <= 127)
- || p[0] >= 224;
-};
-const isPrivate6 = (ip) => {
- const s = ip.toLowerCase();
- if (s === '::1' || s === '::') return true;
- if (s.startsWith('::ffff:')) return isPrivate4(s.slice(7));
- return /^(fc|fd|fe[89ab])/.test(s);
-};
-export const isPrivateIp = (ip) => (ip.includes(':') ? isPrivate6(ip) : isPrivate4(ip));
-
-// A URL the server may fetch: http(s), a default port, a public host.
-async function publicUrl(str) {
- let u;
- try { u = new URL(str); } catch { return null; }
- if (!/^https?:$/.test(u.protocol)) return null;
- if (u.port && u.port !== '80' && u.port !== '443') return null;
- if (u.username || u.password) return null;
- const host = u.hostname.replace(/^\[|\]$/g, '');
- if (!host || host === 'localhost' || /\.(local|internal|localhost)$/i.test(host)) return null;
- let addrs;
- try { addrs = await dns.lookup(host, { all: true }); } catch { return null; }
- if (!addrs.length || addrs.some((a) => isPrivateIp(a.address))) return null;
- return u;
-}
-
-const ENTITIES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>', nbsp: ' ', ndash: '–', mdash: '—', hellip: '…',
- lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', middot: '·', bull: '•', euro: '€', pound: '£' };
-const decodeEntities = (s) => String(s || '')
- .replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ''; } })
- .replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(Number(d)); } catch { return ''; } })
- .replace(/&([a-z]+);/gi, (m, e) => ENTITIES[e.toLowerCase()] ?? m);
-const tidy = (s, max) => decodeEntities(s).replace(/\s+/g, ' ').trim().slice(0, max);
-
-// Every <meta> in the head, whichever way its attributes are ordered.
-function metaTags(html) {
- const out = [];
- const re = /<meta\s+([^>]*?)\/?>/gi;
- let m;
- while ((m = re.exec(html))) {
- const attrs = {};
- const ar = /([a-zA-Z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
- let a;
- while ((a = ar.exec(m[1]))) attrs[a[1].toLowerCase()] = a[3] ?? a[4] ?? a[5] ?? '';
- const key = (attrs.property || attrs.name || attrs.itemprop || '').toLowerCase();
- if (key && attrs.content != null) out.push([key, attrs.content]);
- }
- return out;
-}
-
-// What a card shows, from a page's HTML. Exported for the tests.
-export function parsePreview(html, baseUrl) {
- const head = html.slice(0, MAX_BODY);
- const meta = new Map();
- for (const [k, v] of metaTags(head)) if (!meta.has(k) && v.trim()) meta.set(k, v);
- const pick = (...keys) => { for (const k of keys) if (meta.get(k)) return meta.get(k); return ''; };
- const titleTag = (head.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
- const title = tidy(pick('og:title', 'twitter:title') || titleTag, 200);
- const description = tidy(pick('og:description', 'twitter:description', 'description'), 300);
- let image = pick('og:image:secure_url', 'og:image', 'twitter:image', 'twitter:image:src');
- if (image) {
- try { image = new URL(decodeEntities(image).trim(), baseUrl).href; } catch { image = ''; }
- if (!/^https?:/.test(image)) image = '';
- }
- let site = tidy(pick('og:site_name'), 80);
- if (!site) { try { site = new URL(baseUrl).hostname.replace(/^www\./, ''); } catch {} }
- if (!title && !image) return null;
- return { url: baseUrl, title, description, image, site };
-}
-
-// Read at most MAX_BODY bytes of a response.
-async function readCapped(res) {
- const reader = res.body?.getReader();
- if (!reader) return '';
- const chunks = []; let got = 0;
- while (got < MAX_BODY) {
- const { value, done } = await reader.read();
- if (done) break;
- chunks.push(value); got += value.length;
- }
- try { await reader.cancel(); } catch {}
- return new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
-}
-
-// Fetch a page and describe it, or null when there is nothing to show
-// (not HTML, unreachable, a private host, no title or picture).
-export async function fetchPreview(str) {
- let u = await publicUrl(str);
- for (let hop = 0; u && hop < MAX_HOPS; hop++) {
- let res;
- try {
- res = await fetch(u.href, {
- redirect: 'manual',
- signal: AbortSignal.timeout(TIMEOUT_MS),
- headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en' },
- });
- } catch { return null; }
- if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
- let next;
- try { next = new URL(res.headers.get('location'), u.href).href; } catch { return null; }
- try { await res.body?.cancel(); } catch {}
- u = await publicUrl(next);
- continue;
- }
- if (!res.ok) return null;
- const type = (res.headers.get('content-type') || '').toLowerCase();
- if (!/text\/html|application\/xhtml/.test(type)) { try { await res.body?.cancel(); } catch {} return null; }
- const html = await readCapped(res);
- return parsePreview(html, u.href);
- }
- return null;
-}
diff --git a/names/server.js b/names/server.js
deleted file mode 100644
index 77d758c..0000000
--- a/names/server.js
+++ /dev/null
@@ -1,1840 +0,0 @@
-// BIP-353 name registrar for halwallet.app — gives every hal user a
-// ₿[email protected] payment address.
-//
-// Auth on every write is NIP-98 (HTTP Auth, kind 27235).
-//
-// A name is a DNSSEC-signed TXT record at
-// <name>.user._bitcoin-payment.halwallet.app
-// whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address,
-// extensible later with lno= etc). This service is the write path: it
-// validates a claim signed by the wallet's nostr key, enforces first-come
-// ownership by that key, and mirrors the record into Cloudflare DNS.
-//
-// WHAT THIS SERVICE LEARNS: name ↔ nostr pubkey ↔ payment URI. All of it is
-// public by construction (DNS is public). It cannot spend anything.
-//
-// Deliberately dumb: one JSON state file, no accounts, no email.
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { verifyEvent, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import * as nip44 from 'nostr-tools/nip44';
-import { wrapManyEvents } from 'nostr-tools/nip17';
-import { SimplePool } from 'nostr-tools/pool';
-import { npubEncode, decode as nip19Decode } from 'nostr-tools/nip19';
-import { createHash, randomBytes } from 'node:crypto';
-import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync } from '@scure/bip39';
-import { lnBackend } from '../bridge/ln.js';
-import { ArkManager } from '../src/ark/manager.js';
-import { fetchPreview } from './preview.js';
-import { translate, translateDeepl, languages } from './translate.js';
-import { decodeAddress } from '../src/ark/proto.js';
-import { decodeNoffer } from '../src/noffer.js';
-import { decodeBolt11 } from '../src/ark/lightning.js';
-import { decodeOffer } from '../src/bolt12.js';
-import { registrationIdentity } from './ownership.js';
-
-const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
- || join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, domains: { '<domain>': { zoneId } }, domain (default), cfEmail, cfKey, stateFile,
-// ln: { kind:'socket', path } — the ASP's CLN, for per-name bolt12 offers,
-// forwarder: { mnemonic, ark, esplora, network } — a small float ark wallet
-// that forwards settled offer payments to the name's ark address }
-
-const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims
-const PUBLIC_BASE = CFG.publicBase || 'https://names.coinos.io';
-const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } };
-const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json');
-const TTL = 300; // record TTL: BIP-353 wallets cache by this
-
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// names that must never be claimable
-const RESERVED = new Set([
- 'admin', 'administrator', 'root', 'hal', 'halwallet', 'www', 'mail', 'help',
- 'support', 'info', 'pay', 'payments', 'wallet', 'staging', 'names', 'api',
- 'user', 'users', 'coinos', 'nostr', 'ark', 'test', 'dev', 'security', 'abuse',
-]);
-const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,29}$/;
-// What a NEW name may be: letters and digits, 2–24 — coinos v2's rule
-// (/^[\p{L}\p{N}]{2,24}$/u there), held to ASCII because a name is a DNS
-// label here. NAME_RE stays the wider test for names that already exist
-// (a dozen claimed before the rule: bitcoin-education, barefoot.strider…),
-// v2 migrations carrying their own name, and npub-shaped defaults.
-const NEW_NAME_RE = /^[a-z0-9]{2,24}$/;
-const NEW_NAME_ERR = 'names can only have letters and numbers (2–24)';
-
-let state = (() => {
- try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return { names: {} }; }
-})();
-function persist() {
- mkdirSync(dirname(STATE), { recursive: true });
- const tmp = STATE + '.tmp';
- writeFileSync(tmp, JSON.stringify(state));
- renameSync(tmp, STATE);
-}
-
-// ---------------------------------------------------------------------------
-// Cloudflare DNS
-// ---------------------------------------------------------------------------
-
-const CF = 'https://api.cloudflare.com/client/v4';
-const cfHeaders = {
- 'X-Auth-Email': CFG.cfEmail, 'X-Auth-Key': CFG.cfKey, 'content-type': 'application/json',
-};
-const recordName = (name, domain) => `${name}.user._bitcoin-payment.${domain}`;
-
-async function cfWrite(name, domain, uri, existingId) {
- // TXT character-strings cap at 255 bytes; BIP-353 readers concatenate the
- // chunks in order, so a long ark+lno URI just spans several quoted strings.
- const zoneId = DOMAINS[domain]?.zoneId;
- if (!zoneId) throw new Error('unknown domain');
- const chunks = uri.match(/.{1,255}/g).map((c) => `"${c}"`).join(' ');
- const body = JSON.stringify({
- type: 'TXT', name: recordName(name, domain), content: chunks, ttl: TTL,
- });
- const url = existingId
- ? `${CF}/zones/${zoneId}/dns_records/${existingId}`
- : `${CF}/zones/${zoneId}/dns_records`;
- const r = await fetch(url, { method: existingId ? 'PUT' : 'POST', headers: cfHeaders, body });
- const j = await r.json();
- if (!j.success) throw new Error('dns write failed: ' + JSON.stringify(j.errors).slice(0, 200));
- return j.result.id;
-}
-async function cfDelete(domain, recordId) {
- const zoneId = DOMAINS[domain]?.zoneId;
- if (!zoneId) throw new Error('unknown domain');
- const r = await fetch(`${CF}/zones/${zoneId}/dns_records/${recordId}`, {
- method: 'DELETE', headers: cfHeaders,
- });
- const j = await r.json();
- if (!j.success) throw new Error('dns delete failed');
-}
-
-// ---------------------------------------------------------------------------
-// bolt12 offers + the settle forwarder
-// ---------------------------------------------------------------------------
-// Each name gets a static BOLT 12 offer on the ASP's CLN, published in the
-// DNS record as lno=. Offer payments land on the ASP node, so a forwarder
-// pushes each settled payment on to the name's ark address from a small
-// float wallet (an ordinary arkoor send — free). Custody window: the seconds
-// between LN settle and the ark send. The float must be topped up by the
-// operator; LN income accrues on the node as the offset.
-
-// ---------------------------------------------------------------------------
-// LNURL-pay: the same address, payable by wallets that have never heard of
-// DNS payment instructions (which is most of them).
-//
-// Two ways to produce the invoice, preferring the one that touches no funds:
-// 1. ASK THE RECIPIENT. If their wallet published a CLINK offer key we send
-// it a kind-21001 request over nostr; their wallet (or its service
-// worker) mints an invoice against their own balance and we just hand it
-// back. Nothing passes through us.
-// 2. MINT HERE. Otherwise our node issues the invoice and the settle
-// forwarder pushes the money on to their address, exactly like the
-// BOLT 12 offer path — custodial for the seconds in between.
-const OFFER_KIND = 21001;
-const LNURL_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
-const pool = new SimplePool();
-
-// Our own nostr identity: used to ask recipients for invoices, and to sign
-// NIP-57 zap receipts for payments we mint ourselves.
-if (!state.serviceSk) { state.serviceSk = Buffer.from(generateSecretKey()).toString('hex'); persist(); }
-const SERVICE_SK = Uint8Array.from(Buffer.from(state.serviceSk, 'hex'));
-const SERVICE_PK = getPublicKey(SERVICE_SK);
-
-// ---------------------------------------------------------------------------
-// Welcome DM — every first-time registrant gets a NIP-17 DM from Adam.
-// config: welcome: { sk: '<hex privkey>', message: '…' }. The registration is
-// the one moment we reliably know a new user's npub, so it doubles as the
-// "new user" signal. One per pubkey, ever (state.welcomed).
-// ---------------------------------------------------------------------------
-
-const WELCOME = CFG.welcome && /^[0-9a-f]{64}$/.test(CFG.welcome.sk || '') ? {
- sk: Uint8Array.from(Buffer.from(CFG.welcome.sk, 'hex')),
- message: CFG.welcome.message || 'Welcome to coinos!',
-} : null;
-
-async function inboxRelaysOf(pubkey) {
- try {
- const evs = await pool.querySync(LNURL_RELAYS, { kinds: [10002, 10050], authors: [pubkey] }, { maxWait: 4000 });
- const newest = (kind) => evs.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
- const dm = newest(10050);
- if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r.slice(0, 4); }
- const rl = newest(10002);
- if (rl) return rl.tags.filter((t) => t[0] === 'r' && (!t[2] || t[2] === 'read')).map((t) => t[1]).slice(0, 4);
- } catch {}
- return [];
-}
-
-async function sendWelcome(pubkey, linkedKeys = []) {
- if (!WELCOME) return;
- state.welcomed ||= {};
- // One welcome per PERSON, not per key: a Google sign-up claims twice —
- // the wallet key takes the placeholder name, then the real npub takes
- // its own with the wallet key as manager — and greeting each pubkey put
- // two identical DMs in the same inbox.
- const keys = [pubkey, ...linkedKeys].filter((k) => /^[0-9a-f]{64}$/.test(k || ''));
- const seen = keys.some((k) => state.welcomed[k]);
- for (const k of keys) state.welcomed[k] ||= Date.now();
- persist();
- if (seen) return;
- try {
- // wrapManyEvents returns [sender-copy, recipient] — publish ONLY the
- // recipient's wrap. A sender copy per registrant flooded Adam's own
- // clients with an endless stream of his own welcome message.
- const [, toPeer] = wrapManyEvents(WELCOME.sk, [{ publicKey: pubkey }], WELCOME.message);
- const inbox = await inboxRelaysOf(pubkey);
- const relays = [...new Set([...inbox, ...LNURL_RELAYS])];
- // Count acknowledgements, not attempts: a socket that died under the
- // pool (the home uplink drops every connection at once) swallows a
- // publish without a word, and four welcomes on 2026-10-07 went nowhere
- // while the log said "sent". Nobody heard it → drop those sockets and
- // try again on fresh ones, then say what really happened.
- let acked = await publishAcked(relays, toPeer);
- for (let i = 1; !acked.length && i <= 3; i++) {
- try { pool.close(relays); } catch {}
- await new Promise((r) => setTimeout(r, 5000 * i));
- acked = await publishAcked(relays, toPeer);
- }
- if (acked.length) log(`welcome DM sent to ${pubkey.slice(0, 12)} (${acked.join(', ')})`);
- else {
- for (const k of keys) delete state.welcomed[k]; persist(); // undelivered: the next claim may try again
- log(`welcome DM to ${pubkey.slice(0, 12)} FAILED: no relay accepted it`);
- }
- } catch (e) {
- log('welcome DM failed: ' + e.message);
- }
-}
-
-// Publish and return the relays that answered OK within the time limit.
-async function publishAcked(relays, ev, ms = 10000) {
- const timeout = new Promise((_, j) => setTimeout(() => j(new Error('timeout')), ms));
- const res = await Promise.allSettled(pool.publish(relays, ev).map((p) => Promise.race([p, timeout])));
- return relays.filter((_, i) => res[i].status === 'fulfilled').map((r) => r.replace(/^wss:\/\//, ''));
-}
-
-const lnurlMeta = (address) => JSON.stringify([
- ['text/plain', `Paying ${address}`],
- ['text/identifier', address],
-]);
-
-// Ask the recipient's own CLINK service for an invoice (kind 21001).
-// dest: { pubkey, relays, offerId } — a hal wallet listening on our relays,
-// or an external service (Lightning.Pub beside the user's own node) on the
-// relay its noffer names.
-function requestInvoiceFromWallet(dest, { amountSat, description, zap }) {
- return new Promise((resolve) => {
- let done = false;
- const finish = (v) => { if (!done) { done = true; try { sub.close(); } catch {} resolve(v); } };
- const key = nip44.getConversationKey(SERVICE_SK, dest.pubkey);
- const payload = { offer: dest.offerId, amount_sats: amountSat };
- if (description) payload.description = description.slice(0, 100);
- if (zap) payload.zap = zap;
- const req = finalizeEvent({
- kind: OFFER_KIND, created_at: Math.floor(Date.now() / 1000),
- tags: [['p', dest.pubkey], ['clink_version', '1']],
- content: nip44.encrypt(JSON.stringify(payload), key),
- }, SERVICE_SK);
- const sub = pool.subscribeMany(dest.relays,
- { kinds: [OFFER_KIND], '#e': [req.id], since: Math.floor(Date.now() / 1000) - 5 },
- {
- onevent: (ev) => {
- try {
- const body = JSON.parse(nip44.decrypt(ev.content, key));
- if (body.bolt11) return finish(body.bolt11);
- } catch {}
- },
- });
- Promise.allSettled(pool.publish(dest.relays, req)).catch(() => {});
- setTimeout(() => finish(null), 6000); // services that aren't listening
- });
-}
-
-// Where CLINK invoice requests for this name go. An external noffer the
-// owner registered wins over the hal wallet's own offer key — pointing the
-// name at their node is an explicit choice.
-const clinkDest = (rec) => (rec?.noffer
- ? { pubkey: rec.noffer.pubkey, relays: [...new Set([rec.noffer.relay, ...LNURL_RELAYS])], offerId: rec.noffer.offerId || '' }
- : rec?.offerPk ? { pubkey: rec.offerPk, relays: LNURL_RELAYS, offerId: 'zap_default' } : null);
-
-// An invoice a CLINK service hands back is only usable if it demands exactly
-// the sats owed on the right network — paying (or relaying) one on trust
-// would let a hostile service name any amount.
-// An invoice a payer can actually pay: the right amount, the right network,
-// and not about to expire — an Ark server answers a payment hash it already
-// holds with THAT invoice, and a wallet whose receive counter slipped once
-// handed back a days-old one; the payer saw "invoice expired".
-function usableInvoice(pr, sat, network = null) {
- try {
- const dec = decodeBolt11(pr);
- if (dec.amountMsat !== BigInt(sat) * 1000n) return false;
- if (network && dec.network !== network) return false;
- if (dec.expiresAt && dec.expiresAt < Date.now() + 60_000) return false;
- return true;
- } catch { return false; }
-}
-
-const ln = CFG.ln ? lnBackend(CFG.ln) : null;
-// Optional mutinynet CLN — lets staging sell (play-money) hats. Nothing else
-// uses it: names, offers and forwards remain mainnet affairs.
-const lnMut = CFG.lnMut ? lnBackend(CFG.lnMut) : null;
-
-// -- routing-fee quotes (the /lnquote endpoint) ------------------------------
-
-// Ark locks are sat-granular and the locked surplus IS xpay's maxfee, which
-// binds hard — a 9 msat route under a 0 budget fails outright (verified live:
-// "Could not find route without excessive cost", then the +1-sat retry paid).
-// So sub-sat fees round UP: the honest minimum a client can bring is 1 sat.
-// Making those truly free needs the ASP to bring the msats itself (captaind
-// patch: floor max_routing_fee at 1 sat), not a rounder-down here.
-const quoteSat = (msat) => Math.ceil(msat / 1000);
-
-const _nodeIds = new Map();
-async function ourNodeId(lnq = ln) {
- if (!_nodeIds.has(lnq)) _nodeIds.set(lnq, (await lnq.call('getinfo')).id);
- return _nodeIds.get(lnq);
-}
-
-// Ask askrene (the router xpay itself uses) what delivering amountMsat to
-// dest would cost from here. The maxfee passed in is only the SEARCH cap —
-// generous on purpose, since the user sees and approves the quoted fee
-// before anything is locked.
-async function routeFeeMsat(our, dest, amountMsat, finalCltv, lnq = ln) {
- const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05));
- const r = await lnq.call('getroutes', {
- source: our, destination: dest, amount_msat: amountMsat,
- // localchans FIRST: it re-adds our channels with their advertised fees,
- // so sourcefree must come after it to zero our own hops (verified live —
- // the other order quotes 1 sat on a direct-peer payment).
- layers: ['auto.localchans', 'auto.sourcefree'],
- maxfee_msat: cap, final_cltv: finalCltv,
- });
- if (!r.routes || !r.routes.length) throw new Error('no route found');
- const sent = r.routes.reduce((s, rt) => s + Number(rt.path[0].amount_msat), 0);
- const delivered = r.routes.reduce((s, rt) => s + Number(rt.amount_msat), 0);
- return Math.max(0, sent - delivered);
-}
-let fwd = null; // the float ark wallet
-if (CFG.forwarder?.mnemonic) {
- const account = HDKey.fromMasterSeed(mnemonicToSeedSync(CFG.forwarder.mnemonic)).derive("m/86'/0'/9'");
- fwd = await new ArkManager({
- account,
- storage: {
- load: () => state.fwdArk || null,
- save: (s) => { state.fwdArk = s; persist(); },
- },
- arkUrl: CFG.forwarder.ark, esploraUrl: CFG.forwarder.esplora, network: CFG.forwarder.network || 'mainnet',
- }).init();
- // Read the mailbox before reporting: a top-up sent while we were down (or
- // between syncs) lives there, and balance() only counts what state has seen.
- await fwd.sync().catch(() => {});
- log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`);
-}
-
-async function makeOffer(address) { // "name@domain"
- if (!ln) return null;
- const o = await ln.call('offer', { amount: 'any', description: address });
- return { offerId: o.offer_id, bolt12: o.bolt12 };
-}
-
-const arkParamOf = (uri) => {
- const m = String(uri).match(/[?&]ark=([^&]+)/);
- return m ? decodeURIComponent(m[1]) : null;
-};
-
-// A float arkoor delivery is indistinguishable from any other ark receive on
-// the wallet's side (the mailbox protocol carries no memo), so remember each
-// LN settlement we forwarded that way: the wallet asks /lnreceipts and
-// matches by amount + delivery time to put the ⚡ on the right history rows.
-// Bounded per name; entries age out on the invoice-retention horizon. Only
-// float deliveries are recorded — a viaClink delivery runs the wallet's own
-// LN-receive flow, which tags itself.
-function recordLnReceipt(name, sat, hash) {
- const all = state.lnReceipts ||= {};
- const cutoff = Date.now() - 7 * 86400_000;
- all[name] = [...(all[name] || []).filter((r) => r.ts > cutoff),
- { sat, ts: Date.now(), ...(hash ? { hash } : {}) }].slice(-50);
-}
-
-// Deliver a settled payment to the name's owner. Two ways, and we try the
-// one that works while they're asleep first:
-// 1. an arkoor send from the float — instant, free, needs no cooperation
-// 2. ask their wallet for an invoice and pay it over Lightning — needs no
-// float at all, but only works while something of theirs is listening
-// Failing both, it queues and retries; nothing is ever dropped silently.
-async function forward(name, sat, hash) {
- const rec = state.names[name];
- const dest = rec && arkParamOf(rec.uri);
- const clink = clinkDest(rec);
- if (!dest && !clink) { log(`forward: nowhere to send ${sat} sat for ${name}`); return; }
-
- const viaFloat = async () => {
- if (!dest || !fwd || fwd.balance().spendableSat < sat) return false;
- await fwd.send(dest, sat);
- recordLnReceipt(name, sat, hash);
- persist();
- log(`forwarded ${sat} sat to ${name}`);
- return true;
- };
- const viaClink = async () => {
- if (!clink || !ln) return false;
- const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: `${name} payment` });
- if (!pr) return false;
- if (!usableInvoice(pr, sat, 'mainnet')) {
- log(`forward: ${name}'s service returned an unusable invoice — refusing to pay it`);
- return false;
- }
- await ln.pay(pr, { maxfeeSat: Math.max(2, Math.ceil(sat * 0.005)) });
- log(`delivered ${sat} sat to ${name} over lightning (no float needed)`);
- return true;
- };
- // A name pointed at its owner's own node delivers there first; the float
- // is the fallback. Everyone else gets the instant free arkoor send first.
- for (const step of rec?.noffer ? [viaClink, viaFloat] : [viaFloat, viaClink]) {
- if (await step()) return;
- }
- throw new Error(fwd && dest ? 'insufficient float and the wallet is not listening' : 'no delivery route');
-}
-
-// ---------------------------------------------------------------------------
-// Point of sale — a coinos POS terminal (coinos-pos, an ESP32 keypad) works
-// against a name with a bearer token its owner mints from Settings. The
-// terminal can ring up sales (mint invoices for the name) and learn when they
-// settle; it can never spend. POS invoices are always minted HERE, never via
-// CLINK, so the settle loop sees them and the merchant's phone can be off;
-// delivery is the ordinary forward path (float arkoor), so the sats reach the
-// name's ark address seconds after settlement.
-// ---------------------------------------------------------------------------
-const POS_INVOICE_TTL = 5 * 60_000; // an LNURL tap serves the newest unpaid sale this young
-let ratesCache = { at: 0, rates: null };
-async function fiatRates() {
- if (ratesCache.rates && Date.now() - ratesCache.at < 60_000) return ratesCache.rates;
- const r = await fetch('https://coinos.io/api/rates', { signal: AbortSignal.timeout(5000) });
- if (!r.ok) throw new Error('rates unavailable');
- ratesCache = { at: Date.now(), rates: await r.json() };
- return ratesCache.rates;
-}
-const posTokens = () => (state.posTokens ||= {}); // token -> { key, currency, created }
-const posInvoices = () => (state.posInvoices ||= {}); // payment_hash -> { key, sat, fiat, currency, rate, bolt11, created, paid?, received? }
-function posAuth(req, url) {
- const h = req.headers.get('authorization') || '';
- const tok = (h.match(/^Bearer\s+(.+)$/i) || [])[1] || url.searchParams.get('token') || '';
- const rec = tok && posTokens()[tok];
- if (!rec || !state.names[rec.key]) return null; // a released name takes its tokens with it
- return { token: tok, ...rec };
-}
-function posMintToken(key, currency) {
- const token = randomBytes(32).toString('hex');
- posTokens()[token] = { key, currency: /^[A-Z]{3}$/.test(String(currency || '')) ? currency : 'USD', created: Date.now() };
- persist();
- log(`pos token minted for ${key}`);
- return token;
-}
-// the newest unpaid sale the terminal rang up in the last five minutes, if any
-function posPendingInvoice(key) {
- let best = null;
- for (const [hash, v] of Object.entries(posInvoices())) {
- if (v.key !== key || v.paid || Date.now() - v.created > POS_INVOICE_TTL) continue;
- if (!best || v.created > best.created) best = { hash, ...v };
- }
- return best;
-}
-function sweepPos() {
- const cutoff = Date.now() - 7 * 86400_000;
- for (const [h, v] of Object.entries(posInvoices())) if (v.created < cutoff) delete posInvoices()[h];
-}
-const posSockets = new Set();
-const posPaymentMsg = (hash, v) => JSON.stringify({ type: 'payment', data: { iid: hash, amount: v.received ?? v.sat, tip: 0 } });
-function posSettled(hash, sat) {
- const v = posInvoices()[hash];
- if (!v) return;
- v.paid = Date.now();
- v.received = sat;
- persist();
- for (const ws of posSockets) {
- if (ws.data.key !== v.key || (ws.data.hash && ws.data.hash !== hash)) continue;
- try { ws.send(posPaymentMsg(hash, v)); } catch {}
- }
- log(`pos ${v.key}: ${sat} sat paid`);
-}
-
-// NIP-57: a zap we invoiced is only "a zap" once we publish the receipt,
-// signed by the key our LNURL response advertises.
-async function publishZapReceipt(pending, inv) {
- try {
- const zapReq = JSON.parse(pending.zap);
- const tags = [
- ...zapReq.tags.filter((t) => t[0] === 'p' || t[0] === 'e' || t[0] === 'a'),
- ['P', zapReq.pubkey],
- ['bolt11', pending.bolt11],
- ['description', pending.zap],
- ];
- if (inv.payment_preimage) tags.push(['preimage', inv.payment_preimage]);
- const receipt = finalizeEvent({ kind: 9735, created_at: Math.floor(Date.now() / 1000), tags, content: '' }, SERVICE_SK);
- const relayTag = zapReq.tags.find((t) => t[0] === 'relays');
- const relays = [...new Set([...(relayTag ? relayTag.slice(1) : []), ...LNURL_RELAYS])].slice(0, 8);
- await Promise.allSettled(pool.publish(relays, receipt));
- log(`published a zap receipt for ${pending.key}`);
- } catch (e) { log('zap receipt failed: ' + e.message); }
-}
-
-// ---------------------------------------------------------------------------
-// Hats — cosmetic supporter hats, shown above the buyer's avatar in coinos.
-// Ownership is a paid CLN invoice; the registry here is what clients trust
-// (a kind-0 field would be self-asserted). Money stays on the node: it IS
-// the support. The crown is Adam's and is not for sale.
-// ---------------------------------------------------------------------------
-
-const HAT_ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
-const HAT_PRICES = {
- beanie: 21, trucker: 2100, cowboy: 21000, fedora: 21000,
- bowler: 210000, top: 210000, wizard: 2100000,
-};
-
-// Mutinynet runs a parallel hat economy (invoices minted on the mutinynet
-// CLN, grants in their own registry) so play-money purchases can exercise
-// the whole flow on staging without ever buying a hat mainnet users see.
-const hatNetOf = (v) => (v === 'mutinynet' ? 'mutinynet' : 'mainnet');
-const hatStore = (net) => (net === 'mutinynet' ? (state.hatsMut ||= {}) : (state.hats ||= {}));
-const hatLn = (net) => (net === 'mutinynet' ? lnMut : ln);
-
-function hatRec(pk, net = 'mainnet') {
- const r = hatStore(net)[pk];
- const owned = r ? [...(r.owned || [])] : [];
- if (pk === HAT_ADMIN_PK && !owned.includes('crown')) owned.push('crown');
- const equipped = r ? (r.equipped || null) : (pk === HAT_ADMIN_PK ? 'crown' : null);
- return { owned, equipped };
-}
-
-function grantHat(pk, hat, net = 'mainnet', { equip = true } = {}) {
- const r = hatStore(net)[pk] ||= { owned: [], equipped: null };
- if (!r.owned.includes(hat)) r.owned.push(hat);
- // a fresh purchase goes straight on the head; a GIFT only crowns a bare
- // one — knocking off a hat someone paid for is no way to say thanks
- if (equip || !r.equipped) r.equipped = hat;
-}
-
-async function settleLoop() {
- if (!ln || !fwd) return;
- state.lastPayIndex = state.lastPayIndex || 0;
- for (;;) {
- let inv;
- try {
- inv = await ln.call('waitanyinvoice', { lastpay_index: state.lastPayIndex, timeout: 120 });
- } catch (e) {
- if (!/timed out|Timed out/i.test(e.message)) await new Promise((r) => setTimeout(r, 5000));
- continue;
- }
- state.lastPayIndex = inv.pay_index || state.lastPayIndex;
- persist();
- // hat purchases settle here too — grant and keep the sats (that's the point)
- const hatPending = state.hatInvoices && state.hatInvoices[inv.payment_hash];
- if (hatPending) {
- grantHat(hatPending.pubkey, hatPending.hat, hatNetOf(hatPending.net));
- delete state.hatInvoices[inv.payment_hash];
- persist();
- log(`hat sold: ${hatPending.hat} (${hatNetOf(hatPending.net)}) to ${hatPending.pubkey.slice(0, 12)}`);
- continue;
- }
- const offerId = inv.local_offer_id;
- let name = offerId && Object.keys(state.names).find((n) => state.names[n].offerId === offerId);
- // custom offers (a memo'd bolt12 the user minted for e.g. a mining pool)
- if (!name && offerId && state.offers && state.offers[offerId]) name = state.offers[offerId].key;
- // LNURL invoices we minted: recorded against their payment hash
- const pending = state.invoices && state.invoices[inv.payment_hash];
- if (!name && pending) name = pending.key;
- if (!name) {
- // Usually genuinely not ours (the ASP's and boltz's invoices settle on
- // this node too) — but log it, so a lost mapping is an audit-grep away
- // instead of an archaeology project.
- log(`settle without a name: ${inv.payment_hash} ${inv.amount_received_msat || ''}`);
- continue;
- }
- const sat = Math.floor((inv.amount_received_msat?.msat ?? inv.amount_received_msat ?? 0) / 1000);
- if (!sat) continue;
- if (pending?.pos) posSettled(inv.payment_hash, sat);
- // money has arrived for this name — push-notify their devices (best effort)
- if (CFG.push && CFG.push.url && state.names[name]) {
- fetch(`${CFG.push.url}/notify`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- // this registrar's own network, so the notifier delivers only to the
- // payee's devices on it (a seed makes the same pubkey on both nets)
- body: JSON.stringify({ token: CFG.push.token, pubkey: state.names[name].pubkey, amountSat: sat, net: CFG.forwarder?.network || 'mainnet' }),
- }).catch(() => {});
- }
- // ...and POST to their webhook, if they registered one
- if (state.names[name] && state.names[name].webhook) {
- queueWebhook(name, {
- type: 'payment',
- address: name,
- amount: sat,
- hash: inv.payment_hash,
- ...(inv.payment_preimage ? { preimage: inv.payment_preimage } : {}),
- ...(inv.description ? { memo: String(inv.description).slice(0, 640) } : {}),
- ...(inv.bolt11 ? { bolt11: inv.bolt11 } : {}),
- ts: Date.now(),
- });
- }
- if (pending) {
- if (pending.zap) publishZapReceipt(pending, inv).catch(() => {});
- delete state.invoices[inv.payment_hash];
- persist();
- }
- try {
- await forward(name, sat, inv.payment_hash);
- } catch (e) {
- log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`);
- state.pending = state.pending || [];
- state.pending.push({ name, sat, hash: inv.payment_hash, ts: Date.now() });
- persist();
- }
- }
-}
-// -- float self-top-up -------------------------------------------------------
-// The float drains toward recipients while LN settlements pile up on the CLN
-// — money that can't reach the float by a normal payment, because the top-up
-// invoice lives on the very node holding it (a self-payment the hold plugin
-// never sees). The refill that works is a circular route: out one channel to
-// a peer, straight back in on another, arriving as a real HTLC the ASP
-// intercepts. Costs the peer's forwarding fee (well under 1%).
-// chunkSat caps one attempt: an ASP receive is funded from the vtxo pool,
-// and asking for more than its fresh buckets hold fails whole ("vtxo pool is
-// empty") where two smaller asks would land. Ticks accumulate to the target.
-const TOPUP = { lowWaterSat: 100_000, targetSat: 500_000, chunkSat: 200_000, ...(CFG.floatTopup || {}) };
-let topupCoolUntil = 0;
-async function autoTopupFloat() {
- if (!fwd || !ln || Date.now() < topupCoolUntil) return;
- const floatSat = fwd.balance().spendableSat;
- const queuedSat = (state.pending || []).reduce((n, p) => n + p.sat, 0);
- if (floatSat >= TOPUP.lowWaterSat && floatSat >= queuedSat) return;
- topupCoolUntil = Date.now() + 10 * 60_000; // one attempt, then cool off
- const info = await ln.call('getinfo');
- const chans = ((await ln.call('listpeerchannels')).channels || [])
- .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected);
- // the widest same-peer pair: out on one channel, back on the other
- let pick = null;
- for (const out of chans) {
- for (const back of chans) {
- if (out === back || out.peer_id !== back.peer_id) continue;
- const cap = Math.min(
- Math.floor((out.spendable_msat ?? 0) / 1000) - 10_000,
- Math.floor((back.receivable_msat ?? 0) / 1000) - 10_000);
- if (!pick || cap > pick.cap) pick = { out, back, cap };
- }
- }
- if (!pick || pick.cap < 20_000) { log('float top-up: no circular route capacity'); return; }
- const amountSat = Math.min(Math.max(TOPUP.targetSat, queuedSat + TOPUP.lowWaterSat) - floatSat, pick.cap, TOPUP.chunkSat);
- const a = await fwd.createLnInvoice(amountSat, 'float auto top-up');
- const dec = await ln.call('decode', { string: a.invoice });
- const gossip = await ln.call('listchannels', { short_channel_id: pick.back.short_channel_id });
- const ret = (gossip.channels || []).find((c) => c.source === pick.back.peer_id);
- if (!ret) throw new Error('no gossip for the return channel');
- const amtMsat = amountSat * 1000;
- const feeMsat = Number(ret.base_fee_millisatoshi) + Math.ceil((amtMsat * ret.fee_per_millionth) / 1e6);
- const finalDelay = dec.min_final_cltv_expiry || 18;
- await ln.call('sendpay', {
- route: [
- { id: pick.out.peer_id, channel: pick.out.short_channel_id, amount_msat: amtMsat + feeMsat, delay: finalDelay + ret.delay, style: 'tlv' },
- { id: info.id, channel: pick.back.short_channel_id, amount_msat: amtMsat, delay: finalDelay, style: 'tlv' },
- ],
- payment_hash: a.paymentHash, bolt11: a.invoice, payment_secret: dec.payment_secret,
- });
- // the HTLC sits held until the float wallet claims — drive it through
- for (let i = 0; i < 30; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await fwd.sync().catch(() => {});
- if (fwd.balance().spendableSat > floatSat) break;
- }
- const after = fwd.balance().spendableSat;
- if (after > floatSat) {
- log(`float self-top-up: +${after - floatSat} sat via circular route (fee ${Math.ceil(feeMsat / 1000)} sat) — float now ${after}`);
- for (const p of state.pending || []) p.next = 0; // the queue can go again now
- persist();
- topupCoolUntil = 0;
- } else {
- log('float self-top-up did not settle — cooling off');
- }
-}
-
-// -- float coin upkeep -------------------------------------------------------
-// The float's coins arrive mostly from the ASP's vtxo pool, whose short
-// lifetimes (~3 days) expire long before the app's monthly renewal cadence —
-// and an expired coin forwarded onward strands money the recipient's wallet
-// refuses (a 100k forward died this way on 2026-08-24). Nothing here runs the
-// app's upkeep layer, so renew explicitly: refresh any coin inside a day of
-// its expiry into a fresh round output.
-let floatRefreshAt = 0;
-const REFRESH_MARGIN_BLOCKS = 144;
-async function floatUpkeep() {
- if (!fwd || Date.now() - floatRefreshAt < 30 * 60_000) return;
- const spendables = (fwd.state?.vtxos || []).filter((v) => v.state === 'spendable');
- // never start a round while another action is still in flight
- if (!spendables.length || (fwd.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
- const tip = await fwd.chain.tipHeight();
- const expiring = spendables.filter((v) =>
- v.expiryHeight && v.expiryHeight - tip < REFRESH_MARGIN_BLOCKS && v.amountSat >= 330);
- if (!expiring.length) return;
- floatRefreshAt = Date.now();
- const sat = expiring.reduce((n, v) => n + v.amountSat, 0);
- log(`float upkeep: refreshing ${expiring.length} coins (${sat} sat) nearing expiry`);
- await fwd.refresh(expiring.map((v) => v.id));
- log(`float upkeep: refresh round complete — float now ${fwd.balance().spendableSat} sat`);
-}
-
-// retry queued forwards (e.g. after the float is topped up). Each failure
-// backs off exponentially (1m → 1h cap): a stuck forward must not fire an
-// offer request — and push-wake the recipient's devices — every minute
-// forever.
-setInterval(async () => {
- if (!fwd) return;
- // always sync: it reads ark mail AND drives pending actions (a board
- // waiting out its confirmations, an ln-recv mid-claim, a queued top-up)
- await fwd.sync().catch(() => {});
- await autoTopupFloat().catch((e) => log('float self-top-up failed: ' + e.message));
- await floatUpkeep().catch((e) => log('float upkeep failed: ' + e.message));
- const q = state.pending || [];
- if (!q.length) return;
- const still = [];
- for (const p of q) {
- if (p.next && Date.now() < p.next) { still.push(p); continue; }
- try { await forward(p.name, p.sat, p.hash); }
- catch (e) {
- log(`forward retry failed for ${p.name} (${p.sat} sat, try ${(p.tries || 0) + 1}): ${e.message}`);
- p.tries = (p.tries || 0) + 1;
- p.next = Date.now() + Math.min(3_600_000, 60_000 * 2 ** Math.min(p.tries - 1, 6));
- still.push(p);
- }
- }
- state.pending = still;
- persist();
-}, 60_000);
-
-// ---------------------------------------------------------------------------
-// claims
-// ---------------------------------------------------------------------------
-
-// NIP-98 HTTP Auth (kind 27235). The signature covers this exact URL, method
-// and body hash, so a captured header is useless anywhere else.
-async function checkNip98(req, url, bodyText) {
- const h = req.headers.get('authorization') || '';
- const m = h.match(/^Nostr\s+(.+)$/i);
- if (!m) return { error: 'missing Nostr authorization' };
- let evt;
- try { evt = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; }
- if (evt.kind !== 27235) return { error: 'auth must be kind 27235' };
- if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth event expired' };
- if (!verifyEvent(evt)) return { error: 'bad signature' };
- const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1];
- if ((tag('method') || '').toUpperCase() !== req.method) return { error: 'method mismatch' };
- let got;
- try { got = new URL(tag('u') || ''); } catch { return { error: 'bad url tag' }; }
- if (got.pathname !== url.pathname) return { error: 'url mismatch' };
- if (bodyText) {
- const want = createHash('sha256').update(bodyText).digest('hex');
- if (tag('payload') !== want) return { error: 'payload hash mismatch' };
- }
- return { pubkey: evt.pubkey };
-}
-
-// npub-prefix names are the free default everyone gets, so they must not be
-// squattable: a name that looks like one may only be claimed by that very
-// identity — or by a key that identity nominated as its manager (the wallet
-// key, which is always available, even offline and in the service worker).
-const npubPrefixOwner = (name) => (/^npub1[a-z0-9]+$/.test(name) ? name : null);
-
-// coinos.io names that belong to existing coinos users are reserved for
-// them until the migration gives those users a way to claim their own.
-async function takenByCoinosUser(domain, name, claimantPubkey = null) {
- if (domain !== 'coinos.io') return false;
- try {
- const r = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`);
- if (r.status !== 200) return false;
- const u = await r.json().catch(() => null);
- if (!u) return false;
- // An existing coinos.io account owns its name. The `migrated` flag once
- // gated this and proved meaningless (poppy got squatted through it) —
- // identity is the proof: only a claim SIGNED by the old account's own
- // nostr key may take the name over.
- return !(u.npub && claimantPubkey && u.npub === npubEncode(claimantPubkey));
- } catch { return true; } // can't verify → refuse rather than squat
-}
-
-const validUri = (u) => typeof u === 'string' && /^bitcoin:/i.test(u) && u.length <= 480
- && !/[\s"\\]/.test(u);
-
-// ---------------------------------------------------------------------------
-// webhooks — POST-on-payment for a name (the coinos.io-style merchant notify)
-// ---------------------------------------------------------------------------
-// A name's owner registers a URL (and optional shared secret); every Lightning
-// settlement for that name then POSTs a small JSON body there. Fires on the
-// SETTLEMENT — the money has arrived on our node for that name — independent
-// of how the onward ark delivery goes.
-//
-// The URL is attacker-supplied and we fetch it from inside our own network,
-// so it is vetted hard: https only, no credentials, and every address the
-// host resolves to must be public unicast (loopback, RFC1918, CGNAT/tailscale
-// 100.64/10, link-local, multicast and their v6 kin all refused) — checked at
-// registration AND before every delivery, since DNS can change between them.
-
-const privV4 = (ip) => {
- const p = ip.split('.').map(Number);
- if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true;
- return p[0] === 0 || p[0] === 10 || p[0] === 127
- || (p[0] === 100 && p[1] >= 64 && p[1] <= 127)
- || (p[0] === 169 && p[1] === 254)
- || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
- || (p[0] === 192 && p[1] === 168) || (p[0] === 192 && p[1] === 0)
- || (p[0] === 198 && (p[1] === 18 || p[1] === 19))
- || p[0] >= 224;
-};
-const privAddr = (addr) => {
- const a = addr.toLowerCase();
- if (a.includes(':')) {
- const v4 = a.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
- if (v4) return privV4(v4[1]);
- return a === '::1' || a === '::' || /^f[cd]/.test(a) || /^fe[89ab]/.test(a) || /^ff/.test(a);
- }
- return privV4(a);
-};
-async function vetWebhookUrl(raw) {
- let u;
- try { u = new URL(String(raw)); } catch { return 'invalid url'; }
- if (u.protocol !== 'https:') return 'url must be https';
- if (u.username || u.password) return 'no credentials in the url';
- if (u.hostname.length > 253 || /^(localhost|.*\.(local|internal|lan|home|arpa))$/i.test(u.hostname)) return 'private host';
- try {
- const { lookup } = await import('node:dns/promises');
- const addrs = await lookup(u.hostname, { all: true });
- if (!addrs.length) return 'host does not resolve';
- for (const a of addrs) if (privAddr(a.address)) return 'host resolves to a private address';
- } catch { return 'host does not resolve'; }
- return null;
-}
-
-// Delivery: immediate attempt, then 30s / 5min / 30min retries, then dropped.
-// The queue is persisted so a restart finishes what it owed.
-const WEBHOOK_BACKOFF = [0, 30_000, 300_000, 1_800_000];
-function queueWebhook(key, payload) {
- const rec = state.names[key];
- if (!rec || !rec.webhook || !rec.webhook.url) return;
- state.webhookQueue = state.webhookQueue || [];
- state.webhookQueue.push({ key, payload, tries: 0, nextAt: Date.now() });
- if (state.webhookQueue.length > 500) state.webhookQueue.splice(0, state.webhookQueue.length - 500);
- persist();
- drainWebhooks();
-}
-let webhookDraining = false;
-async function drainWebhooks() {
- if (webhookDraining) return;
- webhookDraining = true;
- try {
- for (;;) {
- const q = state.webhookQueue || [];
- const now = Date.now();
- const item = q.find((x) => x.nextAt <= now);
- if (!item) break;
- const rec = state.names[item.key];
- const hook = rec && rec.webhook;
- if (!hook || !hook.url) { q.splice(q.indexOf(item), 1); persist(); continue; }
- let outcome;
- const bad = await vetWebhookUrl(hook.url);
- if (bad) outcome = 'refused: ' + bad;
- else {
- try {
- const ctl = new AbortController();
- const t = setTimeout(() => ctl.abort(), 10_000);
- const res = await fetch(hook.url, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ ...item.payload, ...(hook.secret ? { secret: hook.secret } : {}) }),
- redirect: 'error',
- signal: ctl.signal,
- });
- clearTimeout(t);
- outcome = res.ok ? 'ok' : `http ${res.status}`;
- } catch (e) { outcome = 'error: ' + (e.message || 'fetch failed').slice(0, 80); }
- }
- rec.webhook.last = { ts: Date.now(), result: outcome, hash: item.payload.hash };
- if (outcome === 'ok' || outcome.startsWith('refused')) {
- q.splice(q.indexOf(item), 1);
- log(`webhook ${item.key} ${item.payload.hash?.slice(0, 12)}: ${outcome}`);
- } else {
- item.tries += 1;
- if (item.tries >= WEBHOOK_BACKOFF.length) {
- q.splice(q.indexOf(item), 1);
- log(`webhook ${item.key} ${item.payload.hash?.slice(0, 12)}: gave up after ${item.tries} tries (${outcome})`);
- } else {
- item.nextAt = Date.now() + WEBHOOK_BACKOFF[item.tries];
- log(`webhook ${item.key}: ${outcome} — retry ${item.tries}/${WEBHOOK_BACKOFF.length - 1}`);
- }
- }
- persist();
- }
- } finally { webhookDraining = false; }
-}
-setInterval(() => { drainWebhooks().catch(() => {}); }, 30_000);
-
-// crude per-IP limiter
-const rate = new Map();
-const previews = new Map(); // url -> { meta, until }
-// rateExempt: IPs (e.g. the operator's own, while testing sign-ups) the
-// per-minute limits never apply to
-const RATE_EXEMPT = new Set(CFG.rateExempt || []);
-function rateOk(ip, limit = 10) {
- if (RATE_EXEMPT.has(ip)) return true;
- const now = Date.now();
- const arr = (rate.get(ip) || []).filter((t) => t > now - 60_000);
- if (arr.length >= limit) return false;
- arr.push(now);
- rate.set(ip, arr);
- if (rate.size > 1000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); }
- return true;
-}
-
-// ---------------------------------------------------------------------------
-// HTTP
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => {
- if (status >= 400) log(`→ ${status} ${JSON.stringify(body).slice(0, 120)}`);
- return new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type,authorization,x-deepl-key',
- 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
- },
- });
-};
-
-Bun.serve({
- port: CFG.port || 8798,
- async fetch(req, server) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
-
- // ---- point of sale (helpers above settleLoop) ----
- if (url.pathname === '/pos/ws') {
- const a = posAuth(req, url);
- if (!a) return json({ error: 'bad token' }, 401);
- if (server.upgrade(req, { data: { key: a.key, hash: null } })) return undefined;
- return json({ error: 'websocket expected' }, 400);
- }
- // The name's owner mints (POST) or revokes all (DELETE) terminal tokens.
- if (url.pathname === '/pos/token' && (req.method === 'POST' || req.method === 'DELETE')) {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body = {};
- try { body = JSON.parse(bodyText || '{}'); } catch { return json({ error: 'bad body' }, 400); }
- const name = String(body.name || '').toLowerCase();
- const domain = String(body.domain || DOMAIN).toLowerCase();
- const key = `${name}@${domain}`;
- const rec = state.names[key];
- if (!rec) return json({ error: 'unknown name' }, 404);
- if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) return json({ error: 'not your name' }, 403);
- if (req.method === 'DELETE') {
- let n = 0;
- for (const [t, v] of Object.entries(posTokens())) if (v.key === key) { delete posTokens()[t]; n++; }
- persist();
- log(`pos tokens revoked for ${key} (${n})`);
- return json({ ok: true, revoked: n });
- }
- const token = posMintToken(key, body.currency);
- // Currency is the merchant's, not the token's: a new token in CAD moves
- // the terminals already paired to this name to CAD as well.
- const { currency } = posTokens()[token];
- for (const v of Object.values(posTokens())) if (v.key === key) v.currency = currency;
- persist();
- return json({ token, name, domain, currency });
- }
- // Operator bootstrap: mint a terminal token for any name.
- if (url.pathname === '/admin/pos/token' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) return json({ error: 'unauthorized' }, 401);
- const body = await req.json().catch(() => ({}));
- const key = `${String(body.name || '').toLowerCase()}@${String(body.domain || DOMAIN).toLowerCase()}`;
- if (!state.names[key]) return json({ error: 'unknown name' }, 404);
- return json({ token: posMintToken(key, body.currency), key });
- }
- // Everything else under /pos/ is the terminal itself, by bearer token.
- if (url.pathname.startsWith('/pos/')) {
- const a = posAuth(req, url);
- if (!a) return json({ error: 'unauthorized' }, 401);
- const [name] = a.key.split('@');
- if (url.pathname === '/pos/me' && req.method === 'GET') {
- return json({ username: name, address: a.key, currency: a.currency });
- }
- // Ring up a sale: {invoice:{fiat}} (the terminal's shape) or {amount} in sats.
- if (url.pathname === '/pos/invoice' && req.method === 'POST') {
- if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
- if (!ln) return json({ error: 'invoicing is closed right now' }, 503);
- const body = await req.json().catch(() => ({}));
- const inv = body.invoice || body;
- const currency = /^[A-Z]{3}$/.test(String(inv.currency || '')) ? inv.currency : a.currency;
- let rates = null;
- try { rates = await fiatRates(); } catch {}
- const rate = rates ? rates[currency] : null;
- let sat = Math.floor(Number(inv.amount) || 0);
- let fiat = Number(inv.fiat) || null;
- if (!sat) {
- if (!(fiat > 0)) return json({ error: 'fiat or amount required' }, 400);
- if (!rate) return json({ error: `no rate for ${currency}` }, 503);
- sat = Math.round((fiat / rate) * 1e8);
- } else if (rate && fiat == null) {
- fiat = Math.round((sat / 1e8) * rate * 100) / 100;
- }
- if (sat < 1) return json({ error: 'amount too small' }, 400);
- try {
- const r = await ln.call('invoice', {
- amount_msat: sat * 1000,
- label: `pos-${a.key}-${Date.now()}`,
- description: lnurlMeta(a.key),
- deschashonly: true, // LUD-06: the tap's lnurlp metadata hashes to this
- expiry: 900,
- });
- const created = Date.now();
- state.invoices ||= {};
- state.invoices[r.payment_hash] = { key: a.key, bolt11: r.bolt11, ts: created, pos: true };
- posInvoices()[r.payment_hash] = { key: a.key, sat, fiat, currency, rate, bolt11: r.bolt11, created };
- sweepPos();
- persist();
- log(`pos ${a.key}: sale ${sat} sat${fiat ? ` (${fiat} ${currency})` : ''}`);
- return json({ id: r.payment_hash, hash: r.bolt11, amount: sat, fiat, currency, rate, created });
- } catch (e) {
- log('pos invoice failed: ' + e.message);
- return json({ error: 'could not create an invoice' }, 500);
- }
- }
- // Paid sales, newest first, in the shape the terminal's history browser reads.
- if (url.pathname === '/pos/payments' && req.method === 'GET') {
- const limit = Math.min(50, parseInt(url.searchParams.get('limit') || '10', 10) || 10);
- const payments = Object.entries(posInvoices())
- .filter(([, v]) => v.key === a.key && v.paid)
- .sort((x, y) => y[1].paid - x[1].paid)
- .slice(0, limit)
- .map(([hash, v]) => ({ id: hash, amount: v.received ?? v.sat, tip: 0, created: v.paid, currency: v.currency, rate: v.rate }));
- return json({ payments, count: payments.length });
- }
- return json({ error: 'not found' }, 404);
- }
-
- // A link in a post, as the card its page describes for itself (Open
- // Graph). Cached a day either way: a page with nothing to show is asked
- // again only after an hour. Public data; the CDN may cache it too.
- if (url.pathname === '/preview' && req.method === 'GET') {
- if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429);
- const target = (url.searchParams.get('url') || '').trim();
- if (!/^https?:\/\/\S{4,2000}$/.test(target)) return json({ error: 'bad url' }, 400);
- const now = Date.now();
- let hit = previews.get(target);
- if (!hit || now > hit.until) {
- const meta = await fetchPreview(target).catch(() => null);
- hit = { meta, until: now + (meta ? 24 : 1) * 3600_000 };
- previews.set(target, hit);
- if (previews.size > 5000) previews.delete(previews.keys().next().value);
- }
- return new Response(JSON.stringify(hit.meta || {}), {
- headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*',
- 'cache-control': 'public, max-age=' + (hit.meta ? 86400 : 3600) },
- });
- }
-
- // A post in another language, in the reader's (LibreTranslate behind a
- // cache — see translate.js). POST { text, target, source? } →
- // { text, source }; GET /translate/languages lists what it can do.
- if (url.pathname === '/translate/languages' && req.method === 'GET') {
- try { return new Response(JSON.stringify(await languages()), { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=3600' } }); }
- catch { return json({ error: 'translation unavailable' }, 503); }
- }
- if (url.pathname === '/translate' && req.method === 'POST') {
- if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
- let body = null;
- try { body = await req.json(); } catch {}
- if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400);
- // a reader's own DeepL key rides in a header: passed on, never kept or logged
- const dk = (req.headers.get('x-deepl-key') || '').trim();
- try { return json(dk ? await translateDeepl(body.text, body.target, dk) : await translate(body.text, body.target, body.source)); }
- catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); }
- }
-
- if (url.pathname === '/health') {
- return json({
- ok: true, domain: DOMAIN, names: Object.keys(state.names).length,
- offers: !!ln, floatSat: fwd ? fwd.balance().spendableSat : null,
- floatAddress: fwd ? fwd.address() : null,
- pendingForwards: (state.pending || []).length,
- });
- }
-
- // Routing-fee quote for an outgoing lightning payment. The wallet locks
- // amount + this fee into its HTLC and the ASP hands the whole surplus to
- // CLN as the routing budget, so the number returned here is exactly what
- // the user pays — zero for invoices minted on our own node (every
- // coinos-to-coinos payment) and for direct peers. Informational only:
- // a wrong quote fails the payment (refunded in full) or overpays a route,
- // it can never spend more than the user chose to lock.
- if (url.pathname === '/lnquote' && req.method === 'GET') {
- // network=mutinynet quotes from the staging ASP's CLN (the same node
- // lnMut points at) so staging wallets get the quoted-route-fee model
- // mainnet has, instead of a flat schedule
- const lnq = (url.searchParams.get('network') || '') === 'mutinynet' ? lnMut : ln;
- if (!lnq) return json({ error: 'no lightning backend' }, 503);
- if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429);
- try {
- const invoice = (url.searchParams.get('invoice') || '').trim().toLowerCase();
- if (!invoice || invoice.length > 4000) return json({ error: 'invoice required' }, 400);
- const dec = await lnq.call('decode', { string: invoice });
- if (dec.valid === false) return json({ error: 'invalid invoice' }, 400);
- const dest = dec.payee || dec.invoice_node_id;
- const amountMsat = Math.floor(Number(url.searchParams.get('amount_msat')) || 0)
- || Number(dec.amount_msat || dec.invoice_amount_msat || 0);
- if (!dest) return json({ error: 'no destination in invoice' }, 400);
- if (!amountMsat || amountMsat < 0) return json({ error: 'amount required' }, 400);
- const our = await ourNodeId(lnq);
- if (dest === our) return json({ feeMsat: 0, feeSat: 0, direct: true });
- const finalCltv = dec.min_final_cltv_expiry || 18;
- try {
- const feeMsat = await routeFeeMsat(our, dest, amountMsat, finalCltv, lnq);
- return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false });
- } catch (e) {
- // A bolt12 invoice with blinded paths: its node id is a blinded
- // key no router knows. Quote = route to a path's public entry
- // node + the blinded segment's advertised payinfo fees.
- const paths = dec.invoice_paths || [];
- let best = null;
- for (const p of paths) {
- const pay = p.payinfo || {};
- const entry = p.first_node_id;
- if (!entry) continue;
- const f = (pay.fee_base_msat || 0) + Math.ceil(amountMsat * (pay.fee_proportional_millionths || 0) / 1_000_000);
- try {
- const toEntry = entry === our ? 0 : await routeFeeMsat(our, entry, amountMsat + f, finalCltv, lnq);
- if (best == null || toEntry + f < best) best = toEntry + f;
- } catch {}
- }
- if (best != null) return json({ feeMsat: best, feeSat: quoteSat(best), direct: false, blinded: true });
- // Private bolt11 recipient: not in gossip, reachable only via the
- // invoice's route hints. Quote = route to the hint's public entry
- // node + the hint hops' advertised fees.
- const hint = (dec.routes || [])[0];
- if (!hint || !hint.length) throw e;
- let hintFeeMsat = 0;
- let carried = amountMsat;
- for (const hop of [...hint].reverse()) {
- const f = (hop.fee_base_msat || 0) + Math.ceil(carried * (hop.fee_proportional_millionths || 0) / 1_000_000);
- hintFeeMsat += f; carried += f;
- }
- const entry = hint[0].pubkey;
- const toEntry = entry === our ? 0
- : await routeFeeMsat(our, entry, amountMsat + hintFeeMsat, finalCltv, lnq);
- const feeMsat = toEntry + hintFeeMsat;
- return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false, hinted: true });
- }
- } catch (e) {
- return json({ error: e.message || 'no route found' }, 404);
- }
- }
-
- // Recent LN settlements forwarded to the caller's name(s) as float
- // arkoors — the wallet matches them against its receive movements to
- // mark which ones actually arrived over Lightning. NIP-98 authed and
- // owner-only: this is a feed of incoming amounts.
- if (url.pathname === '/lnreceipts' && req.method === 'GET') {
- if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429);
- const a = await checkNip98(req, url, '');
- if (a.error) return json({ error: a.error }, 401);
- const receipts = [];
- for (const [key, rec] of Object.entries(state.names)) {
- if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) continue;
- for (const r of (state.lnReceipts || {})[key] || []) {
- receipts.push({ name: key, sat: r.sat, ts: r.ts, hash: r.hash || null });
- }
- }
- receipts.sort((x, y) => y.ts - x.ts);
- return json({ receipts: receipts.slice(0, 100) });
- }
-
- // Prefix search over registered names — powers recipient search in the
- // wallet's DMs. Public data (every name is a public DNS record).
- if (url.pathname === '/search' && req.method === 'GET') {
- if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
- const q = (url.searchParams.get('q') || '').toLowerCase().trim();
- if (q.length < 2 || q.length > 30) return json({ results: [] });
- const results = Object.entries(state.names)
- .filter(([key]) => key.split('@')[0].startsWith(q))
- .sort(([a], [b]) => a.length - b.length || (a < b ? -1 : 1))
- .slice(0, 10)
- .map(([key, r]) => ({ address: key, name: key.split('@')[0], pubkey: r.pubkey }));
- return json({ results });
- }
-
- // A user's own BOLT 12 offers with a custom memo — what a mining pool or
- // payroll wants to send to. Settled payments ride the same forwarder as
- // the name's default offer, so they land in the user's Spending balance.
- if (url.pathname === '/offer' && (req.method === 'POST' || req.method === 'GET')) {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = req.method === 'POST' ? await req.text() : '';
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- const key = Object.keys(state.names)
- .find((k) => state.names[k].pubkey === a.pubkey || state.names[k].manager === a.pubkey);
- if (!key) return json({ error: 'claim a name first' }, 400);
- state.offers = state.offers || {};
- if (req.method === 'GET') {
- return json({ offers: Object.entries(state.offers)
- .filter(([, o]) => o.key === key)
- .map(([offerId, o]) => ({ offerId, memo: o.memo, bolt12: o.bolt12, created: o.created })) });
- }
- if (!ln) return json({ error: 'offers unavailable' }, 503);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const memo = String(body.memo || '').slice(0, 120).trim();
- const mine = Object.entries(state.offers).filter(([, o]) => o.key === key);
- const dup = mine.find(([, o]) => o.memo === memo);
- if (dup) return json({ ok: true, offerId: dup[0], memo, bolt12: dup[1].bolt12, existing: true });
- if (mine.length >= 10) return json({ error: 'too many offers' }, 400);
- let o;
- try {
- // `issuer` keeps two users' identical memos from collapsing onto one
- // CLN offer (it dedupes by the offer's contents).
- o = await ln.call('offer', { amount: 'any', description: memo || key, issuer: key });
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- state.offers[o.offer_id] = { key, memo, bolt12: o.bolt12, created: Date.now() };
- persist();
- log(`offer for ${key}: ${memo ? JSON.stringify(memo) : '(no memo)'}`);
- return json({ ok: true, offerId: o.offer_id, memo, bolt12: o.bolt12 });
- }
-
- // --- NIP-05 --------------------------------------------------------
- // Every claimed name doubles as a nostr identifier: name@domain verifies
- // against the owner's key. Public data, CORS comes with json().
- if ((url.pathname === '/.well-known/nostr.json' || url.pathname === '/nostr.json') && req.method === 'GET') {
- const name = (url.searchParams.get('name') || '').toLowerCase();
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- if (!NAME_RE.test(name)) return json({ names: {} });
- const rec = state.names[`${name}@${domain}`];
- return json(rec ? { names: { [name]: rec.pubkey } } : { names: {} });
- }
-
- // Availability / lookup. Public data (it's DNS).
- const m = url.pathname.match(/^\/name\/([a-z0-9._-]{1,30})$/);
- if (m && req.method === 'GET') {
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- const rec = state.names[`${m[1]}@${domain}`];
- // granted: the record's holder is where the legacy coinos.io account's
- // own session sent the name (/migrate-grant) — coinos.io's v3 bridge
- // forwards that account's incoming payments here only on such proof
- if (rec) {
- const g = domain === 'coinos.io' && (state.migrations || {})[`${m[1]}@coinos.io`];
- const keys = [rec.pubkey, rec.manager].filter(Boolean);
- const granted = !!g && (keys.includes(g.pubkey) || (!!g.manager && keys.includes(g.manager)));
- return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null, lno: rec.lno || null, granted });
- }
- const invalid = !NEW_NAME_RE.test(m[1]) && !npubPrefixOwner(m[1]);
- const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || invalid || await takenByCoinosUser(domain, m[1]);
- return json({ name: m[1], domain, taken: reserved, reserved, ...(invalid ? { invalid: true } : {}) });
- }
-
- // Which name(s) does a wallet key own? Lets an imported seed find its
- // username again without the user retyping it. Public data (DNS is public).
- const pm = url.pathname.match(/^\/pubkey\/([0-9a-f]{64})$/);
- if (pm && req.method === 'GET') {
- // optional domain filter, so a staging claim can't shadow a mainnet
- // name (or vice versa) when a wallet asks who it is
- const domFilter = (url.searchParams.get('domain') || '').toLowerCase();
- const mine = Object.entries(state.names)
- .filter(([k, r]) => r.pubkey === pm[1] && (!domFilter || k.endsWith('@' + domFilter)))
- // a custom name outranks the free npub placeholder regardless of
- // which record is fresher — a placeholder re-claimed on a new device
- // must not shadow the name the user actually chose
- .sort((a, b) => {
- const ph = ([k]) => (/^npub1/.test(k.split('@')[0]) ? 1 : 0);
- return ph(a) - ph(b) || (b[1].updated || 0) - (a[1].updated || 0);
- });
- if (!mine.length) return json({});
- const [key, r] = mine[0];
- const [name, domain] = key.split('@');
- return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null, lno: r.lno || null });
- }
-
- // --- hats ------------------------------------------------------------
-
- // Batched display lookup: which hat is each of these heads wearing?
- // Public data (a hat is worn in public by construction).
- if (url.pathname === '/hats' && req.method === 'GET') {
- const net = hatNetOf(url.searchParams.get('net'));
- const pks = (url.searchParams.get('pks') || '')
- .split(',').filter((p) => /^[0-9a-f]{64}$/.test(p)).slice(0, 100);
- const hats = {};
- for (const pk of pks) hats[pk] = hatRec(pk, net).equipped;
- return json({ hats });
- }
-
- // The shop view: everything one pubkey owns, plus the price list (the
- // client shows what the server would actually charge).
- const hm = url.pathname.match(/^\/hats\/([0-9a-f]{64})$/);
- if (hm && req.method === 'GET') {
- return json({ ...hatRec(hm[1], hatNetOf(url.searchParams.get('net'))), prices: HAT_PRICES });
- }
-
- // Operator gift: drop a hat into someone's rack (x-admin-token). Takes
- // hex or npub; giftable ids only — the crown stays singular. The gift
- // sits in the rack rather than force-equipping over a paid hat.
- if (url.pathname === '/hats/grant' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- let b;
- try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); }
- let pk = String(b.pubkey || '');
- if (/^npub1/.test(pk)) {
- try { pk = nip19Decode(pk).data; } catch { return json({ error: 'bad npub' }, 400); }
- }
- if (!/^[0-9a-f]{64}$/.test(pk)) return json({ error: 'bad pubkey' }, 400);
- const hat = String(b.hat || '');
- // giftable but never sold: the beta hard hat, and the knight's helm
- if (!(hat in HAT_PRICES) && !['construction', 'knight'].includes(hat)) return json({ error: 'no such hat' }, 400);
- const net = hatNetOf(b.net);
- // the operator may say the gift goes straight on the head (a hat made
- // for this one person); by default it only crowns a bare one
- grantHat(pk, hat, net, { equip: b.equip === true });
- persist();
- log(`hat gifted: ${hat} (${net}) to ${pk.slice(0, 12)}${b.equip === true ? ', worn' : ''}`);
- return json(hatRec(pk, net));
- }
-
- if (url.pathname === '/hats/invoice' && req.method === 'POST') {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const hat = String(body.hat || '');
- const net = hatNetOf(body.net);
- const sat = HAT_PRICES[hat];
- if (!sat) return json({ error: 'no such hat' }, 400);
- if (hatRec(a.pubkey, net).owned.includes(hat)) return json({ error: 'you already own that hat' }, 400);
- const node = hatLn(net);
- if (!node) return json({ error: 'the hat stand is closed right now' }, 503);
- try {
- const inv = await node.call('invoice', {
- amount_msat: sat * 1000,
- label: `hat-${hat}-${a.pubkey.slice(0, 8)}-${Date.now()}`,
- description: `coinos hat: ${hat}`,
- expiry: 900,
- });
- state.hatInvoices ||= {};
- state.hatInvoices[inv.payment_hash] = { pubkey: a.pubkey, hat, sat, net, ts: Date.now() };
- for (const [h2, v] of Object.entries(state.hatInvoices)) {
- if (Date.now() - v.ts > 7 * 86400_000) delete state.hatInvoices[h2];
- }
- persist();
- log(`hat invoice: ${hat} (${sat} sat, ${net}) for ${a.pubkey.slice(0, 12)}`);
- return json({ invoice: inv.bolt11, paymentHash: inv.payment_hash, sat });
- } catch (e) {
- log('hat invoice failed: ' + e.message);
- return json({ error: 'could not create an invoice' }, 500);
- }
- }
-
- // The buyer pings us right after paying, so the hat appears without
- // waiting on the settle loop. Idempotent with it: whoever runs first
- // grants, the other finds it done.
- if (url.pathname === '/hats/claim' && req.method === 'POST') {
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const hash = String(body.paymentHash || '');
- const hat = String(body.hat || '');
- const net = hatNetOf(body.net);
- const pending = (state.hatInvoices || {})[hash];
- if (!pending) {
- // the settle loop may have beaten us to it
- if (hat && hatRec(a.pubkey, net).owned.includes(hat)) return json(hatRec(a.pubkey, net));
- return json({ error: 'unknown invoice' }, 404);
- }
- if (pending.pubkey !== a.pubkey) return json({ error: 'not your invoice' }, 403);
- const node = hatLn(hatNetOf(pending.net));
- if (!node) return json({ error: 'the hat stand is closed right now' }, 503);
- try {
- const r = await node.call('listinvoices', { payment_hash: hash });
- if ((r.invoices || [])[0]?.status !== 'paid') return json({ error: 'invoice not paid yet' }, 402);
- } catch (e) {
- return json({ error: 'could not check the invoice' }, 500);
- }
- grantHat(pending.pubkey, pending.hat, hatNetOf(pending.net));
- delete state.hatInvoices[hash];
- persist();
- log(`hat sold (claim): ${pending.hat} (${hatNetOf(pending.net)}) to ${pending.pubkey.slice(0, 12)}`);
- return json(hatRec(a.pubkey, hatNetOf(pending.net)));
- }
-
- if (url.pathname === '/hats/equip' && req.method === 'POST') {
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const hat = body.hat ? String(body.hat) : null;
- const net = hatNetOf(body.net);
- if (hat && !hatRec(a.pubkey, net).owned.includes(hat)) return json({ error: 'you do not own that hat' }, 403);
- const r = hatStore(net)[a.pubkey] ||= { owned: [], equipped: null };
- r.equipped = hat;
- persist();
- return json(hatRec(a.pubkey, net));
- }
-
- // --- LNURL-pay (LUD-06/16, NIP-57 zaps) ------------------------------
- const lm = url.pathname.match(/^\/(?:\.well-known\/lnurlp|lnurlp)\/([a-z0-9._-]{1,30})$/i);
- if (lm && req.method === 'GET') {
- const name = lm[1].toLowerCase();
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- const rec = state.names[`${name}@${domain}`];
- if (!rec) return json({ status: 'ERROR', reason: 'unknown address' }, 404);
- const address = `${name}@${domain}`;
- // A terminal that just rang up a sale: the tap pays exactly that
- // invoice (coinos.io's /p/<name> rule — newest unpaid sale under five
- // minutes old). Otherwise the payer picks the amount.
- const pos = posPendingInvoice(address);
- if (pos) return json({
- tag: 'payRequest',
- callback: `${PUBLIC_BASE}/lnurlp/${name}/cb?domain=${encodeURIComponent(domain)}&pos=${pos.hash}`,
- minSendable: pos.sat * 1000,
- maxSendable: pos.sat * 1000,
- metadata: lnurlMeta(address),
- commentAllowed: 0,
- allowsNostr: false,
- });
- return json({
- tag: 'payRequest',
- callback: `${PUBLIC_BASE}/lnurlp/${name}/cb?domain=${encodeURIComponent(domain)}`,
- minSendable: 1000,
- maxSendable: 500000000,
- metadata: lnurlMeta(address),
- commentAllowed: 200,
- allowsNostr: true,
- nostrPubkey: SERVICE_PK,
- });
- }
-
- const cb = url.pathname.match(/^\/lnurlp\/([a-z0-9._-]{1,30})\/cb$/i);
- if (cb && req.method === 'GET') {
- const name = cb[1].toLowerCase();
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- const key = `${name}@${domain}`;
- const rec = state.names[key];
- if (!rec) return json({ status: 'ERROR', reason: 'unknown address' }, 404);
- const msat = parseInt(url.searchParams.get('amount') || '0', 10);
- // a POS sale: hand back the invoice the terminal minted, nothing else
- const posHash = url.searchParams.get('pos');
- if (posHash) {
- const v = posInvoices()[posHash];
- if (!v || v.key !== key || v.paid || Date.now() - v.created > POS_INVOICE_TTL)
- return json({ status: 'ERROR', reason: 'that sale has expired — tap again' }, 410);
- if (msat !== v.sat * 1000) return json({ status: 'ERROR', reason: 'amount must match the sale' }, 400);
- return json({ pr: v.bolt11, routes: [] });
- }
- if (!msat || msat < 1000) return json({ status: 'ERROR', reason: 'amount too small' }, 400);
- const sat = Math.floor(msat / 1000);
- const zap = url.searchParams.get('nostr') || null;
- const comment = (url.searchParams.get('comment') || '').slice(0, 200);
-
- // 1. the recipient's own wallet or node service, if it is listening
- const clink = clinkDest(rec);
- if (clink) {
- // staging wallets mint signet invoices — only mainnet is checked
- const net = domain === 'staging.coinos.io' ? null : 'mainnet';
- // asked twice: a wallet that handed back a stale invoice (its Ark
- // server re-serving an old payment hash) usually gets it right on
- // the next index
- for (let attempt = 0; attempt < 2; attempt++) {
- const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: comment, zap });
- if (!pr) break;
- if (usableInvoice(pr, sat, net)) {
- let hash = '', exp = '';
- try { const d = decodeBolt11(pr); hash = String(d.paymentHash || '').slice(0, 12); exp = d.expiresAt ? new Date(d.expiresAt).toISOString().slice(0, 16) : ''; } catch {}
- log(`lnurl ${key}: recipient minted ${sat} sat (hash ${hash}, expires ${exp})`);
- return json({ pr, routes: [] });
- }
- let why = 'unusable';
- try { const d = decodeBolt11(pr); if (d.expiresAt && d.expiresAt < Date.now() + 60_000) why = 'expired ' + new Date(d.expiresAt).toISOString().slice(0, 16); } catch {}
- log(`lnurl ${key}: recipient returned an ${why} invoice${attempt ? ' again — minting instead' : ' — asking again'}`);
- }
- }
- // Staging names are mutinynet wallets: only their own wallet can mint a
- // right-network invoice. Minting on our mainnet node would take real
- // sats for play money and strand them (the forwarder can't deliver to
- // a testnet ark address).
- if (domain === 'staging.coinos.io') return json({ status: 'ERROR', reason: 'recipient is offline' }, 503);
- // 2. our node, forwarded on settle
- if (!ln || !fwd) return json({ status: 'ERROR', reason: 'recipient is offline' }, 503);
- try {
- const description = zap || lnurlMeta(key);
- const inv = await ln.call('invoice', {
- amount_msat: msat,
- label: `lnurl-${key}-${Date.now()}`,
- description,
- deschashonly: true, // LUD-06: the invoice carries sha256(metadata)
- expiry: 900,
- });
- state.invoices = state.invoices || {};
- state.invoices[inv.payment_hash] = { key, zap, bolt11: inv.bolt11, ts: Date.now() };
- // forget invoices nobody paid
- // Keep mappings as long as the invoice could possibly settle: a 1h
- // sweep once erased a mapping while the settle loop was catching up
- // after downtime, stranding a paid 1000 sats unattributed (found
- // again only via the description_hash). CLN invoices default to a
- // 7-day expiry — match it.
- for (const [h, v] of Object.entries(state.invoices)) {
- if (Date.now() - v.ts > 7 * 86400_000) delete state.invoices[h];
- }
- persist();
- log(`lnurl ${key}: issued ${sat} sat (forwarding on settle)`);
- return json({ pr: inv.bolt11, routes: [] });
- } catch (e) {
- log('lnurl invoice failed: ' + e.message);
- return json({ status: 'ERROR', reason: 'could not create an invoice' }, 500);
- }
- }
-
- // Top up the float over Lightning: it mints an invoice against its own
- // ark balance, which anyone (in practice, the operator's node) can pay.
- // Guarded by a token from the config file — this endpoint is public.
- // Chain relay for wallets whose networks can't reach any public esplora
- // (one tester's ISP blackholes mempool.space AND blockstream — every
- // chain-touching wallet feature died for it). A narrow passthrough to
- // mempool.space: only the four paths the ark manager speaks, the tip
- // briefly cached so polling wallets cost one upstream call per few
- // seconds, not one each.
- if (url.pathname.startsWith('/esplora/')) {
- const sub = url.pathname.slice('/esplora'.length);
- const okPath = sub === '/blocks/tip/height'
- || /^\/tx\/[0-9a-f]{64}\/(hex|status)$/.test(sub)
- || (sub === '/tx' && req.method === 'POST');
- if (!okPath) return json({ error: 'not proxied' }, 404);
- if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429);
- try {
- if (sub === '/blocks/tip/height' && globalThis._tipCache && Date.now() - globalThis._tipCache.t < 5000) {
- return new Response(globalThis._tipCache.v, { headers: { 'access-control-allow-origin': '*' } });
- }
- const r = await fetch('https://mempool.space/api' + sub,
- req.method === 'POST' ? { method: 'POST', body: await req.text() } : undefined);
- const body = await r.text();
- if (sub === '/blocks/tip/height' && r.ok) globalThis._tipCache = { v: body, t: Date.now() };
- return new Response(body, {
- status: r.status,
- headers: {
- 'access-control-allow-origin': '*',
- 'content-type': r.headers.get('content-type') || 'text/plain',
- },
- });
- } catch {
- return json({ error: 'upstream unreachable' }, 502);
- }
- }
-
- // Legacy migration hand-off. coinos.io's migrate action (running with
- // the OLD account's authenticated session — that session IS the proof of
- // ownership) tells us which v3 identity may claim the released name.
- // Without this the claim guard has nothing to go on: identity-only proof
- // (the old account's npub signing the claim) never matches a v3 wallet's
- // key, so every migrated claim 409'd forever. The grant names either the
- // destination address (we look up its record) or a pubkey directly (the
- // operator-rescue form).
- if (url.pathname === '/migrate-grant' && req.method === 'POST') {
- const tok = (req.headers.get('authorization') || '').replace(/^Bearer\s+/i, '');
- if (!CFG.migrateToken || tok !== CFG.migrateToken) return json({ error: 'unauthorized' }, 401);
- let b;
- try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); }
- const name = String(b.name || '').toLowerCase();
- if (!NAME_RE.test(name)) return json({ error: 'bad name' }, 400);
- let pubkey = /^[0-9a-f]{64}$/.test(String(b.pubkey || '')) ? b.pubkey : null;
- let manager = null;
- if (!pubkey && b.to) {
- const [tn, td] = String(b.to).toLowerCase().split('@');
- const rec = state.names[`${tn}@${td}`];
- if (!rec) return json({ error: 'destination is not registered here' }, 404);
- pubkey = rec.pubkey;
- manager = rec.manager || null;
- }
- if (!pubkey) return json({ error: 'no destination' }, 400);
- (state.migrations ||= {})[`${name}@coinos.io`] = { pubkey, manager, ts: Date.now() };
- persist();
- log(`migrate-grant: ${name}@coinos.io -> ${pubkey.slice(0, 8)}`);
- return json({ ok: true });
- }
-
- if (url.pathname === '/admin/topup' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- const body = await req.json().catch(() => ({}));
- const sat = Math.floor(body.sat || 0);
- if (!sat || sat < 1000) return json({ error: 'amount too small' }, 400);
- try {
- const a = await fwd.createLnInvoice(sat, 'names float top-up');
- log(`float top-up invoice for ${sat} sat`);
- return json({ invoice: a.invoice, paymentHash: a.paymentHash });
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- }
-
- // Board on-chain sats into the float: returns a P2TR funding address.
- // The funding tx must pay EXACTLY the requested amount to it AT VOUT 0
- // (the board outpoint is fixed to index 0), then /admin/board/complete
- // with the txid; confirmations are waited out by the sync loop.
- if (url.pathname === '/admin/board' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- const body = await req.json().catch(() => ({}));
- const sat = Math.floor(body.sat || 0);
- if (!sat || sat < 10_000) return json({ error: 'amount too small' }, 400);
- try {
- const b = await fwd.startBoard(sat);
- log(`float board started: ${sat} sat -> ${b.fundingAddress} (fee ${b.feeSat} sat)`);
- return json(b);
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- }
- if (url.pathname === '/admin/board/complete' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- const body = await req.json().catch(() => ({}));
- if (!body.actionId || !/^[0-9a-f]{64}$/.test(body.txid || '')) {
- return json({ error: 'actionId and txid required' }, 400);
- }
- try {
- const a = await fwd.completeBoard(body.actionId, body.txid);
- return json({ ok: true, step: a.step, confs: a.confs ?? 0, needConfs: a.needConfs ?? null });
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- }
-
- // Drive an in-flight lightning receive forward (top-ups settle async).
- if (url.pathname === '/admin/drive' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- for (const a of fwd.pendingActions()) { await fwd.driveLn(a.id).catch(() => {}); }
- await fwd.sync().catch(() => {});
- return json({ ok: true, floatSat: fwd.balance().spendableSat });
- }
-
- if (url.pathname === '/register' && req.method === 'POST') {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let claim;
- try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const auth = { pubkey: a.pubkey };
- const name = String(claim.name || '').toLowerCase();
- const domain = String(claim.domain || 'halwallet.app').toLowerCase();
- if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400);
- if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400);
- // A migration grant (the legacy account's authenticated session told
- // us where the name is going) trumps BOTH the reserved list and the
- // legacy reservation: a reserved word that is also a real coinos.io
- // account (the house account `coinos` itself) can only ever arrive
- // here through a migration, and the identity check below can never
- // pass for a v3 wallet key. Reserved without a grant stays reserved.
- const grant = (state.migrations || {})[`${name}@coinos.io`];
- const granted = domain === 'coinos.io' && grant
- && (grant.pubkey === auth.pubkey || (grant.manager && grant.manager === auth.pubkey));
- if (RESERVED.has(name) && !granted) return json({ error: 'name is reserved' }, 400);
- if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400);
-
- const key = `${name}@${domain}`;
- const existing = state.names[key];
- // owner OR the manager key the owner nominated may update a record
- if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) {
- return json({ error: 'name is taken' }, 409);
- }
- if (!existing && !granted && !npubPrefixOwner(name) && !NEW_NAME_RE.test(name)) return json({ error: NEW_NAME_ERR }, 400);
- if (!existing && !granted && await takenByCoinosUser(domain, name, auth.pubkey)) return json({ error: 'name is taken' }, 409);
- // an npub-shaped name belongs to that identity alone
- if (npubPrefixOwner(name)) {
- const managerOk = existing && existing.manager === auth.pubkey;
- if (!npubEncode(auth.pubkey).startsWith(name) && !managerOk) {
- return json({ error: 'that name belongs to another Nostr identity' }, 403);
- }
- }
- if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400);
- // Delivery noffer: absent = keep, '' = clear, a code = point Lightning
- // delivery at the owner's own CLINK service (their node's invoice
- // minter). Decoded here so a typo can't become a delivery black hole.
- let noffer = existing?.noffer;
- if (claim.noffer !== undefined) {
- if (!claim.noffer) noffer = undefined;
- else {
- try {
- const d = decodeNoffer(String(claim.noffer));
- if (!/^wss?:\/\/./.test(d.relay || '')) throw new Error('offer code names no relay');
- noffer = { pubkey: d.pubkey, relay: d.relay, offerId: d.offerId || '', raw: String(claim.noffer) };
- } catch (e) { return json({ error: 'bad noffer: ' + e.message }, 400); }
- }
- }
- // Own BOLT 12 offer: same absent/''/set semantics. When set, the DNS
- // record carries THEIR lno — senders' fetchinvoice goes straight to
- // their node and no money ever touches this service.
- let lno = existing?.lno;
- if (claim.lno !== undefined) {
- if (!claim.lno) lno = undefined;
- else {
- try {
- const d = decodeOffer(String(claim.lno).trim());
- if (domain !== 'staging.coinos.io' && d.network && d.network !== 'mainnet') {
- throw new Error(`offer is for ${d.network}`);
- }
- lno = String(claim.lno).trim().toLowerCase();
- } catch (e) { return json({ error: 'bad offer: ' + e.message }, 400); }
- }
- }
- // A corrupt ark address recorded here is a payment black hole: forwards
- // fail forever while the sats sit on the node (goyslop's 190k sat sat
- // behind an invalid checksum). Refuse it at the door.
- const arkDest = arkParamOf(claim.uri);
- if (arkDest) {
- try { decodeAddress(arkDest); }
- catch { return json({ error: 'the ark address in the uri does not decode' }, 400); }
- }
-
- // The record's lno=: the owner's own offer when they set one, else a
- // static offer on our node with the settle forwarder behind it.
- let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null;
- if (lno) {
- // their node answers fetchinvoice directly now — retire our stand-in
- if (offer && ln) await ln.call('disableoffer', { offer_id: offer.offerId }).catch(() => {});
- offer = null;
- } else if (!offer) {
- try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); }
- }
- const pubLno = lno || offer?.bolt12;
- const published = pubLno ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${pubLno}` : claim.uri;
-
- const recordId = await cfWrite(name, domain, published, existing?.recordId);
- state.names[key] = {
- ...registrationIdentity({ existing, signer: auth.pubkey, manager: claim.manager, grant, granted }),
- uri: published, recordId, updated: Date.now(), domain,
- // the wallet's CLINK offer key, so LNURL can ask it for invoices
- offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk,
- // the owner's own CLINK service / BOLT 12 offer, when they set one
- ...(noffer ? { noffer } : {}),
- ...(lno ? { lno } : {}),
- // a registered webhook survives record updates — it's cleared only
- // through its own endpoint
- ...(existing?.webhook ? { webhook: existing.webhook } : {}),
- offerId: offer?.offerId, bolt12: offer?.bolt12,
- };
- persist();
- log(`${existing ? 'updated' : 'registered'} ${key} for ${auth.pubkey.slice(0, 12)}`);
- if (!existing) sendWelcome(state.names[key].pubkey, [state.names[key].manager]).catch(() => {});
- return json({ ok: true, name, address: key, record: recordName(name, domain) });
- }
-
- // Webhook management: the name's owner (or nominated manager) points a
- // URL at their own system and every settled payment for the name POSTs
- // there. GET shows the current config plus the last delivery's outcome;
- // an empty/absent url on POST (or DELETE) clears it.
- if (url.pathname === '/webhook' && (req.method === 'POST' || req.method === 'DELETE' || req.method === 'GET')) {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = req.method === 'GET' ? '' : await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body = {};
- if (bodyText) { try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } }
- const name = String((req.method === 'GET' ? url.searchParams.get('name') : body.name) || '').toLowerCase();
- const domain = String((req.method === 'GET' ? url.searchParams.get('domain') : body.domain) || 'coinos.io').toLowerCase();
- const key = `${name}@${domain}`;
- const rec = state.names[key];
- if (!rec) return json({ error: 'unknown name' }, 404);
- if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) return json({ error: 'not your name' }, 403);
- if (req.method === 'GET') {
- return json({ ok: true, name, domain, webhook: rec.webhook
- ? { url: rec.webhook.url, secret: rec.webhook.secret || null, set: rec.webhook.set, last: rec.webhook.last || null }
- : null });
- }
- if (req.method === 'DELETE' || !body.url) {
- delete rec.webhook;
- persist();
- log(`webhook cleared for ${key}`);
- return json({ ok: true, webhook: null });
- }
- const rawUrl = String(body.url).slice(0, 512);
- const bad = await vetWebhookUrl(rawUrl);
- if (bad) return json({ error: bad }, 400);
- const secret = body.secret != null ? String(body.secret).slice(0, 128) : undefined;
- rec.webhook = { url: rawUrl, ...(secret ? { secret } : {}), set: Date.now() };
- persist();
- log(`webhook set for ${key} → ${new URL(rawUrl).hostname}`);
- return json({ ok: true, webhook: { url: rawUrl, secret: secret || null } });
- }
-
- if (url.pathname === '/register' && req.method === 'DELETE') {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let claim;
- try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const auth = { pubkey: a.pubkey };
- const name = String(claim.name || '').toLowerCase();
- const domain = String(claim.domain || 'halwallet.app').toLowerCase();
- const key = `${name}@${domain}`;
- const existing = state.names[key];
- if (!existing) return json({ error: 'unknown name' }, 404);
- if (existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) return json({ error: 'not your name' }, 403);
- await cfDelete(domain, existing.recordId).catch(() => {});
- if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {});
- delete state.names[key];
- persist();
- log(`released ${key}`);
- return json({ ok: true });
- }
-
- return json({ error: 'not found' }, 404);
- },
- // POS terminals: one socket per device. `subscribe` names the sale it is
- // waiting on; a `payment` message arrives when that sale settles.
- // Heartbeats and anything else are ignored.
- websocket: {
- open(ws) {
- posSockets.add(ws);
- ws.send(JSON.stringify({ type: 'connected', data: ws.data.key }));
- },
- message(ws, raw) {
- let m;
- try { m = JSON.parse(String(raw)); } catch { return; }
- if (m?.type !== 'subscribe') return;
- const id = String(m.data?.id || m.data || '');
- const v = posInvoices()[id];
- if (!v || v.key !== ws.data.key) return;
- ws.data.hash = id;
- // a sale that settled between the mint and this subscribe is still a sale
- if (v.paid) { try { ws.send(posPaymentMsg(id, v)); } catch {} }
- },
- close(ws) { posSockets.delete(ws); },
- },
-});
-
-settleLoop().catch((e) => log('settle loop died:', e.message));
-log(`names registrar for ${DOMAIN} on :${CFG.port || 8798} — ${Object.keys(state.names).length} name(s), offers ${ln ? 'on' : 'off'}, forwarder ${fwd ? 'on' : 'off'}`);
diff --git a/names/translate.js b/names/translate.js
deleted file mode 100644
index 36ab8cf..0000000
--- a/names/translate.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// Post translation for coinos v3: a thin, cached front for a LibreTranslate
-// container on the same docker network. Notes are public, so nothing private
-// passes through here; the cache keys on the text itself, so a popular post
-// is translated once whoever asks.
-import { createHash } from 'node:crypto';
-import { translatable, translatePieces } from '../src/translate-split.js';
-
-const LT = process.env.LT_URL || 'http://libretranslate:5000';
-const MAX_CHARS = 5000;
-const CACHE_MAX = 20000;
-const BUSY_MAX = 4; // LibreTranslate is CPU-bound: a few at once, the rest wait their turn
-const cache = new Map(); // sha256(target|text) -> { text, source }
-let busy = 0;
-const waiting = [];
-let langs = null, langsAt = 0;
-
-async function slot() {
- if (busy < BUSY_MAX) { busy++; return; }
- await new Promise((r) => waiting.push(r));
- busy++;
-}
-function release() { busy--; const next = waiting.shift(); if (next) next(); }
-
-// The languages the container has models for: { code: [targets] }
-export async function languages() {
- if (langs && Date.now() - langsAt < 3600_000) return langs;
- const r = await fetch(LT + '/languages', { signal: AbortSignal.timeout(5000) });
- if (!r.ok) throw new Error('languages ' + r.status);
- const list = await r.json();
- langs = Object.fromEntries(list.map((l) => [l.code, l.targets || []]));
- if (langs['zh-Hans']) langs.zh = langs['zh-Hans']; // the app says 'zh'
- langsAt = Date.now();
- return langs;
-}
-
-// The app's locale codes ↔ LibreTranslate's ('zh' is Simplified there).
-const norm = (c) => String(c || '').toLowerCase().split(/[-_]/)[0];
-const toLt = (c) => (c === 'zh' ? 'zh-Hans' : c);
-
-export async function translate(text, target, source = 'auto') {
- text = String(text || '');
- target = norm(target);
- source = source && source !== 'auto' ? norm(source) : 'auto';
- if (!text.trim()) return { text: '', source };
- if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
- const have = await languages();
- if (!have[toLt(target)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
- const key = createHash('sha256').update(target + '|' + source + '|' + text).digest('hex');
- const hit = cache.get(key);
- if (hit) { cache.delete(key); cache.set(key, hit); return hit; }
- const { prose, tags } = translatable(text, target);
- if (!prose.length && !tags.length) return { text, source: source === 'auto' ? null : source, nothing: true };
- await slot();
- try {
- const call = async (path, body) => {
- const r = await fetch(LT + path, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
- });
- const out = await r.json().catch(() => ({}));
- if (!r.ok) throw Object.assign(new Error(out.error || path + ' ' + r.status), { status: r.status === 400 ? 422 : 502 });
- return out;
- };
- let from = source, confidence = null;
- if (from === 'auto') {
- const d = await call('/detect', { q: [...prose.map((p) => p.prose), ...tags.map((p) => p.keep.slice(1).replace(/_/g, ' '))].join('\n') });
- from = norm(d[0]?.language) || null;
- confidence = d[0]?.confidence ?? null;
- if (!from) throw Object.assign(new Error('unknown language'), { status: 422 });
- }
- if (from === target) return { text, source: from, confidence };
- if (!have[toLt(from)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
- const translated = await translatePieces(text, async (qs) => {
- const r = await call('/translate', { q: qs, source: toLt(from), target: toLt(target), format: 'text' });
- return Array.isArray(r.translatedText) ? r.translatedText : [r.translatedText];
- }, target);
- const out = { text: translated, source: from, confidence };
- cache.set(key, out);
- if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
- return out;
- } finally { release(); }
-}
-
-// DeepL, with the reader's own API key (sent per request; never stored, and
-// not part of the cache key beyond the provider). DeepL refuses browsers
-// (no CORS), which is the only reason this passes through here. A free key
-// ends in ":fx" and has its own host.
-const DEEPL_TARGET = { en: 'EN-US', pt: 'PT-BR', zh: 'ZH-HANS' };
-export async function translateDeepl(text, target, key, fetchImpl = fetch) {
- text = String(text || '');
- target = norm(target);
- if (!text.trim()) return { text: '', source: null };
- if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
- if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(key)) throw Object.assign(new Error('bad DeepL key'), { status: 401 });
- const ck = createHash('sha256').update('deepl|' + target + '|' + text).digest('hex');
- const hit = cache.get(ck);
- if (hit) { cache.delete(ck); cache.set(ck, hit); return hit; }
- const { prose, tags } = translatable(text, target);
- if (!prose.length && !tags.length) return { text, source: null, nothing: true };
- const host = /:fx$/i.test(key) ? 'https://api-free.deepl.com' : 'https://api.deepl.com';
- let from = null;
- const translated = await translatePieces(text, async (qs) => {
- const r = await fetchImpl(host + '/v2/translate', {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: 'DeepL-Auth-Key ' + key },
- body: JSON.stringify({ text: qs, target_lang: DEEPL_TARGET[target] || target.toUpperCase() }),
- signal: AbortSignal.timeout(20_000),
- });
- if (r.status === 403 || r.status === 401) throw Object.assign(new Error('DeepL refused the key'), { status: 401 });
- if (r.status === 456) throw Object.assign(new Error('DeepL quota exceeded'), { status: 402 });
- if (r.status === 400) throw Object.assign(new Error('DeepL can’t translate into that language'), { status: 422 });
- if (!r.ok) throw Object.assign(new Error('DeepL ' + r.status), { status: 502 });
- const j = await r.json();
- const out = (j.translations || []).map((x) => x.text);
- from ||= norm(j.translations?.[0]?.detected_source_language) || null;
- if (out.length !== qs.length) throw Object.assign(new Error('DeepL answered short'), { status: 502 });
- return out;
- }, target);
- const out = { text: from === target ? text : translated, source: from };
- cache.set(ck, out);
- if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
- return out;
-}
diff --git a/nwcpush/.gitignore b/nwcpush/.gitignore
deleted file mode 100644
index 45bbf0e..0000000
--- a/nwcpush/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/
-config.json
diff --git a/nwcpush/config.example.json b/nwcpush/config.example.json
deleted file mode 100644
index 6b1abd8..0000000
--- a/nwcpush/config.example.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "port": 8797,
- "relays": ["wss://relay.coinos.io", "wss://relay.damus.io", "wss://nos.lol"],
- "vapid": { "subject": "mailto:[email protected]", "publicKey": "REPLACE", "privateKey": "REPLACE" },
- "stateFile": "./data/registrations.json",
- "maxPubkeysPerDevice": 20,
- "staleDays": 30
-}
diff --git a/nwcpush/server.js b/nwcpush/server.js
deleted file mode 100644
index 6d56f84..0000000
--- a/nwcpush/server.js
+++ /dev/null
@@ -1,529 +0,0 @@
-// NWC push notifier — step 1 of making hal answer wallet-connect requests
-// while it is closed.
-//
-// hal is a web wallet: a service worker cannot hold a relay socket open, so
-// something has to tell the browser "a request arrived, wake up". That is all
-// this does. It watches the relays for NIP-47 requests (kind 23194) addressed
-// to the wallet-service pubkeys a device has registered, and sends that device
-// a Web Push. The service worker then does the actual work — fetch, decrypt,
-// pay, reply — in the browser, with the keys never leaving it.
-//
-// WHAT THIS SERVICE LEARNS: the wallet-service pubkeys you register, and the
-// timing of requests to them. That is a real metadata leak and is the price of
-// push. It never sees request contents (encrypted to keys it does not have),
-// never sees your wallet seed, and can never move funds. The worst it can do
-// is fail to wake you, or lie about a wake-up — both of which cost nothing.
-//
-// Deliberately dumb: no database, no accounts. Registrations live in one JSON
-// file and expire if a device stops refreshing them.
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { SimplePool } from 'nostr-tools/pool';
-import { verifyEvent } from 'nostr-tools/pure';
-import webpush from 'web-push';
-
-// crude per-IP publish limiter: 20/min is far above a wallet's real reply rate
-const pubRate = new Map();
-function rateOk(ip) {
- const now = Date.now();
- const arr = (pubRate.get(ip) || []).filter((t) => t > now - 60_000);
- if (arr.length >= 20) return false;
- arr.push(now);
- pubRate.set(ip, arr);
- if (pubRate.size > 500) for (const [k, v] of pubRate) { if (!v.some((t) => t > now - 60_000)) pubRate.delete(k); }
- return true;
-}
-
-const CFG = JSON.parse(readFileSync(process.env.NWCPUSH_CONFIG
- || join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, relays: [...], vapid: { publicKey, privateKey, subject },
-// stateFile, maxPubkeysPerDevice, staleDays }
-
-const RELAYS = CFG.relays || ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
-const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'registrations.json');
-const MAX_PK = CFG.maxPubkeysPerDevice || 20;
-const STALE_MS = (CFG.staleDays || 30) * 86400_000;
-const REQ_KIND = 23194;
-const OFFER_KIND = 21001;
-
-webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey);
-
-// Two ways to reach a device.
-//
-// Web Push is the browser's, and on Android it is Firebase's — a phone
-// without Google Play Services (GrapheneOS and friends) can't subscribe at
-// all. UnifiedPush is the other way: the user runs a distributor, the app
-// gets an endpoint, and anyone wanting to reach the device POSTs the payload
-// to it. No encryption envelope and no VAPID — the endpoint is the secret,
-// which is why we never log one in full.
-//
-// A subscription marked `unifiedpush` is the second kind; everything else is
-// a browser's. One function, so every send path serves both.
-const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string');
-async function deliver(sub, payload, opts = {}) {
- if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts);
- const res = await fetch(sub.endpoint, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: payload,
- signal: AbortSignal.timeout(8000),
- });
- if (!res.ok) {
- // shaped like web-push's errors so the callers' 404/410 pruning works
- const err = new Error(`unifiedpush ${res.status}`);
- err.statusCode = res.status;
- throw err;
- }
- return res;
-}
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// ---------------------------------------------------------------------------
-// registrations: { [servicePubkey]: [{ sub, id, updated }] }
-// ---------------------------------------------------------------------------
-
-let regs = {};
-// user-facing notifications: { [endpointId]: { sub, ptags: [], authors: [], updated } }
-// ptags → DMs / zap receipts p-tagged at the user; authors → community
-// stream pubkeys (kind 1059 wraps authored by a channel's derived key).
-let notifyRegs = {};
-(() => {
- try {
- const raw = JSON.parse(readFileSync(STATE, 'utf8'));
- if (raw && raw.nwc) { regs = raw.nwc; notifyRegs = raw.notify || {}; }
- else regs = raw || {}; // pre-notify state file
- } catch {}
-})();
-function persist() {
- mkdirSync(dirname(STATE), { recursive: true });
- const tmp = STATE + '.tmp';
- writeFileSync(tmp, JSON.stringify({ nwc: regs, notify: notifyRegs }));
- renameSync(tmp, STATE);
-}
-// A device that stops refreshing drops off, so a stale endpoint can't keep us
-// subscribed to a pubkey forever.
-function prune() {
- const cutoff = Date.now() - STALE_MS;
- let dropped = 0;
- for (const pk of Object.keys(regs)) {
- regs[pk] = (regs[pk] || []).filter((r) => (r.updated || 0) > cutoff);
- if (!regs[pk].length) { delete regs[pk]; dropped++; }
- }
- for (const id of Object.keys(notifyRegs)) {
- if ((notifyRegs[id].updated || 0) <= cutoff) { delete notifyRegs[id]; dropped++; }
- }
- if (dropped) { persist(); log(`pruned ${dropped} stale registration(s)`); resubscribe(); }
-}
-
-const watchedPubkeys = () => Object.keys(regs);
-
-// ---------------------------------------------------------------------------
-// relay watcher
-// ---------------------------------------------------------------------------
-
-const pool = new SimplePool();
-let sub = null;
-let lastResub = 0;
-
-const notifyPtags = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.ptags || []))];
-const notifyAuthors = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.authors || []))];
-
-let notifySub = null;
-function resubscribeNotify() {
- try { notifySub?.close(); } catch {}
- notifySub = null;
- const ptags = notifyPtags();
- const authors = notifyAuthors();
- if (!ptags.length && !authors.length) return;
- const filters = [];
- // DMs / direct invites / zap receipts land p-tagged at the user
- // ...as do replies and mentions (kind 1 naming the user) and reactions (7)
- if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#p': ptags, since: Math.floor(Date.now() / 1000) });
- // community chat: wraps authored by a channel's derived stream key
- if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) });
- // one sub per filter — the pool API takes a single filter object
- notifySub = filters.map((f) => pool.subscribeMany(RELAYS, f, { onevent: onNotifyEvent }));
- notifySub.close = function () { for (const x of this) { try { x.close(); } catch {} } };
- log(`notify: watching ${ptags.length} ptag(s), ${authors.length} stream author(s)`);
-}
-
-// throttle: chat bursts collapse to one push per device per window
-// DMs are throttled barely at all now: the device drops the ones it decides
-// are noise (a stranger, or our own sent-copy), and a server-side cooldown
-// would let a discarded push swallow the friend's message that followed it.
-const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000 };
-const lastPush = new Map(); // endpointId:reason -> ts
-const seenNotifyEvents = new Map(); // event id -> ts
-function onNotifyEvent(ev) {
- if (seenNotifyEvents.has(ev.id)) return;
- seenNotifyEvents.set(ev.id, Date.now());
- if (seenNotifyEvents.size > 4000) {
- const cut = Date.now() - 600_000;
- for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k);
- }
- const p = ev.tags?.find((t) => t[0] === 'p')?.[1];
- // A public reply or mention: the note is readable here, so the device
- // gets what it needs to say who and what — an excerpt, and whether it
- // answers a post (e tag) or just names the person.
- if (ev.kind === 1) {
- const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
- const reply = (ev.tags || []).some((t) => t[0] === 'e' && t[1]);
- const extra = { from: ev.pubkey, reply, text: String(ev.content || '').replace(/\s+/g, ' ').slice(0, 140) };
- for (const [id, r] of Object.entries(notifyRegs)) {
- const mine = r.ptags || [];
- if (mine.includes(ev.pubkey)) continue; // their own post, not news
- if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'mention', extra).catch(() => {});
- }
- return;
- }
- // A like or emoji on the user's post. Opt-in by an explicit flag: an app
- // too old to send one has no words for it and would call it chat.
- if (ev.kind === 7) {
- const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
- const extra = { from: ev.pubkey, emoji: String(ev.content || '+').slice(0, 16) };
- for (const [id, r] of Object.entries(notifyRegs)) {
- const mine = r.ptags || [];
- if (r.reasons?.reaction !== true || mine.includes(ev.pubkey)) continue;
- if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'reaction', extra).catch(() => {});
- }
- return;
- }
- const zap = ev.kind === 9735 || ev.kind === 9737;
- const reason = zap ? 'payment'
- : p && notifyPtags().includes(p) ? 'dm' : 'chat';
- // A wrapped DM hides its sender from us by design, so we can't decide here
- // whether it's from a friend or a stranger — we hand the whole wrap to the
- // device, which holds the only key that can open it. Web Push tops out
- // around 4KB after encryption; anything larger goes as a bare nudge and the
- // device shows its generic notification.
- const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {};
- // a coinos zap receipt says how much in the clear
- const amt = ev.kind === 9737 ? parseInt(ev.tags?.find((t) => t[0] === 'amount')?.[1], 10) : 0;
- if (amt > 0) extra.amountSat = amt;
- for (const [id, r] of Object.entries(notifyRegs)) {
- const hit = reason === 'chat'
- ? (r.authors || []).includes(ev.pubkey)
- : p && (r.ptags || []).includes(p);
- // a zap is its own category for apps that know one; older apps keep
- // getting it as a payment
- if (hit) pushNotify(id, r, zap && typeof r.reasons?.zap === 'boolean' ? 'zap' : reason, extra).catch(() => {});
- }
-}
-
-async function pushNotify(id, r, reason, extra) {
- if (r.reasons && r.reasons[reason] === false) return; // opted out of this category
- const key = id + ':' + reason;
- const last = lastPush.get(key) || 0;
- if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return;
- lastPush.set(key, Date.now());
- const payload = JSON.stringify({ type: 'notify', reason, ...extra });
- // Android holds a 'normal' push while the phone dozes and batches it into
- // the next maintenance window — and drops it once TTL runs out first. A
- // phone in a pocket missed most zaps and mentions this way (a laptop never
- // dozes, so it got them all). Money and people talking to you go 'high'
- // and keep for a day; reactions and community chatter can wait.
- const loud = ['payment', 'zap', 'dm', 'mention'].includes(reason);
- const opts = loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' };
- try {
- try {
- await deliver(r.sub, payload, opts);
- } catch (e) {
- // A payload the push service won't take (413, or the library refusing
- // the size) must not cost the notification itself — send the bare nudge
- // and let the device show its generic message.
- if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e;
- log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`);
- await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), opts);
- }
- log(`notify(${reason}) -> device ${id}`);
- } catch (e) {
- // 404/410 = gone; 403 = the sub was made against a different VAPID key
- // and can NEVER receive a push (a stale key rotation), so prune it too —
- // the client re-subscribes fresh against the current key on next open.
- if (e.statusCode === 404 || e.statusCode === 410 || e.statusCode === 403) {
- delete notifyRegs[id];
- persist();
- resubscribeNotify();
- log(`dropped a dead notify endpoint ${id} (${e.statusCode})`);
- } else {
- log(`notify(${reason}) push failed for ${id}: ${e.statusCode || e.message}`);
- }
- }
-}
-
-function resubscribe() {
- resubscribeNotify();
- const pks = watchedPubkeys();
- try { sub?.close(); } catch {}
- sub = null;
- if (!pks.length) { log('nothing registered for nwc; not subscribed'); return; }
- lastResub = Date.now();
- sub = pool.subscribeMany(
- RELAYS,
- // only live traffic: a replayed old request must not wake devices.
- // NOTE: nostr-tools ≥2.23 wants a single filter object, NOT an array —
- // an array serializes as an invalid REQ that relays reject with CLOSED.
- { kinds: [REQ_KIND, OFFER_KIND], '#p': pks, since: Math.floor(Date.now() / 1000) },
- {
- onevent: (ev) => {
- const target = ev.tags?.find((t) => t[0] === 'p')?.[1];
- if (target) { noteRequest(ev.id); wake(target, ev.id, ev).catch(() => {}); }
- },
- },
- );
- // Track replies for the requests we forwarded, so an auto-answering worker
- // can ask "was this already answered by an open device elsewhere?" before
- // it pays. Only e-tags of requests we have seen are recorded.
- try { answeredSub?.close(); } catch {}
- answeredSub = pool.subscribeMany(
- RELAYS,
- { kinds: [RES_KIND, OFFER_KIND], since: Math.floor(Date.now() / 1000) },
- {
- onevent: (ev) => {
- for (const t of ev.tags || []) {
- if (t[0] === 'e' && recentReqs.has(t[1])) answeredIds.set(t[1], Date.now());
- }
- },
- },
- );
- log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`);
-}
-
-// requests we pushed recently, and which of them got a reply
-const RES_KIND = 23195;
-let answeredSub = null;
-const recentReqs = new Map(); // request event id -> ts
-const answeredIds = new Map(); // request event id -> ts a 23195 e-tagged it
-// First come, first pays: request event id -> { by, ts }. Every awake device
-// of a wallet sees every pay request, and two spending at once each lock an
-// HTLC for the same invoice — the loser's refusal then reached the client as
-// a failure. A device claims a request before spending; a claim held by
-// someone else means stand down. Memory only, minutes of life, no keys.
-const claims = new Map();
-function noteRequest(id) {
- recentReqs.set(id, Date.now());
- if (recentReqs.size > 2000) pruneRequests();
-}
-function pruneRequests() {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k);
- for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k);
- for (const [k, c] of claims) if (c.ts < cut) claims.delete(k);
-}
-
-// De-dupe: the same event arrives from several relays.
-const recentlyWoken = new Map();
-async function wake(servicePk, eventId, ev) {
- const key = servicePk + ':' + eventId;
- if (recentlyWoken.has(key)) return;
- recentlyWoken.set(key, Date.now());
- if (recentlyWoken.size > 2000) {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentlyWoken) if (t < cut) recentlyWoken.delete(k);
- }
-
- const targets = regs[servicePk] || [];
- if (!targets.length) return;
- // The payload carries the (still encrypted, still client-signed) request
- // event so an auto-answering service worker can act on it without holding
- // a relay socket. Web Push payloads cap around 4KB — an oversized event is
- // omitted and the worker falls back to waking the user.
- const clean = ev && {
- id: ev.id, pubkey: ev.pubkey, created_at: ev.created_at,
- kind: ev.kind, tags: ev.tags, content: ev.content, sig: ev.sig,
- };
- let payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk, event: clean });
- if (payload.length > 3800) payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk });
- let ok = 0;
- for (const r of targets) {
- try {
- await deliver(r.sub, payload, { TTL: 60, urgency: 'high' });
- ok++;
- } catch (e) {
- // 404/410 mean the browser dropped the subscription: forget it.
- if (e.statusCode === 404 || e.statusCode === 410) {
- regs[servicePk] = (regs[servicePk] || []).filter((x) => x.id !== r.id);
- if (!regs[servicePk].length) delete regs[servicePk];
- persist();
- log(`dropped an expired push endpoint for ${servicePk.slice(0, 12)}`);
- } else {
- log('push failed:', e.statusCode || e.message);
- }
- }
- }
- log(`woke ${ok}/${targets.length} device(s) for ${servicePk.slice(0, 12)}`);
-}
-
-// ---------------------------------------------------------------------------
-// HTTP
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type',
- 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
- },
-});
-
-const server = Bun.serve({
- port: CFG.port || 8797,
- async fetch(req) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
-
- if (url.pathname === '/health') {
- return json({
- ok: true, relays: RELAYS, watching: watchedPubkeys().length,
- subscribed: !!sub, lastResub,
- });
- }
-
- // The browser needs this to create a PushSubscription.
- if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey });
-
- // Publish-back for the auto-answering service worker: it cannot hold a
- // relay socket, so it hands us its (encrypted, service-key-signed) reply.
- // Strictly kind 23195, size-capped, signature-verified, rate-limited —
- // this is a reply pipe, not an open relay proxy.
- if (url.pathname === '/publish' && req.method === 'POST') {
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const ev = body?.event;
- if (!ev || ![RES_KIND, OFFER_KIND].includes(ev.kind)) return json({ error: 'kind 23195/21001 events only' }, 400);
- if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400);
- if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400);
- const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1];
- try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {}
- if (eTag) answeredIds.set(eTag, Date.now());
- log(`published a reply for request ${(eTag || '?').slice(0, 12)}`);
- return json({ ok: true });
- }
-
- // Server-to-server: the names registrar reports a settled receive for a
- // pubkey. Token-gated; pushes 'payment received' to that user's devices.
- if (url.pathname === '/notify' && req.method === 'POST') {
- const body = await req.json().catch(() => null);
- if (!CFG.notifyToken || body?.token !== CFG.notifyToken) return json({ error: 'forbidden' }, 403);
- const pk = body?.pubkey;
- if (!/^[0-9a-f]{64}$/.test(pk || '')) return json({ error: 'pubkey required' }, 400);
- // Network scoping: the same pubkey exists on mainnet and staging, so a
- // payment goes only to devices on its own network. A device that hasn't
- // re-registered since this shipped has net=null and still gets it (so
- // no one goes dark mid-rollout); once both sides send a net, mismatches
- // are filtered. A caller that omits net pushes to everyone (legacy).
- const wantNet = typeof body.net === 'string' ? body.net : null;
- let n = 0;
- for (const [id, r] of Object.entries(notifyRegs)) {
- if (!(r.ptags || []).includes(pk)) continue;
- if (wantNet && r.net && r.net !== wantNet) continue;
- pushNotify(id, r, 'payment', { amountSat: Number(body.amountSat) || undefined }).catch(() => {});
- n++;
- }
- return json({ ok: true, devices: n });
- }
-
- // Claim a pay request before spending on it. The first claimant (or the
- // same one again) gets ok:true; anyone else ok:false and stands down.
- if (url.pathname === '/claim' && req.method === 'POST') {
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const id = body?.event, by = String(body?.by || '').slice(0, 64);
- if (!/^[0-9a-f]{64}$/.test(id || '') || !by) return json({ error: 'event and by required' }, 400);
- if (claims.size > 5000) pruneRequests();
- const cur = claims.get(id);
- if (cur && cur.by !== by && Date.now() - cur.ts < 300_000) return json({ ok: false, by: cur.by });
- claims.set(id, { by, ts: Date.now() });
- return json({ ok: true });
- }
-
- // Was a request we pushed already answered by someone? Lets a worker
- // avoid double-paying (or shouting an error over) another device's reply.
- if (url.pathname === '/answered') {
- const id = url.searchParams.get('event') || '';
- return json({ answered: answeredIds.has(id) });
- }
-
- // A device registers its push endpoint plus the wallet-service pubkeys it
- // wants woken for. Re-post periodically to stay alive.
- if (url.pathname === '/register' && req.method === 'POST') {
- const body = await req.json().catch(() => null);
- const sub_ = body?.subscription;
- const hasNwc = Array.isArray(body?.servicePubkeys);
- const pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p));
- // user-facing notification watch (messages + payments) — independent of
- // the NWC registration so either can refresh without clobbering the other
- if (body?.notify && sub_?.endpoint) {
- const hex = (a, cap) => [...new Set((a || []).filter((p) => /^[0-9a-f]{64}$/.test(p)))].slice(0, cap);
- const id = Bun.hash(sub_.endpoint).toString(36);
- // per-category opt-outs; absent means everything on (older clients)
- const reasons = {};
- for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction']) {
- if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k];
- }
- notifyRegs[id] = {
- sub: sub_, updated: Date.now(),
- ptags: hex(body.notify.ptags, 8),
- authors: hex(body.notify.authors, 64),
- reasons,
- // the network this device is on: the same seed yields the same
- // pubkey on mainnet and staging, so a payment push is delivered
- // only to devices on the payment's network (see /notify)
- net: typeof body.notify.net === 'string' ? body.notify.net.slice(0, 16) : null,
- };
- persist();
- resubscribeNotify();
- if (!hasNwc) return json({ ok: true, notify: true });
- }
- if (!sub_?.endpoint || !pks.length) return json({ error: 'subscription and servicePubkeys required' }, 400);
- if (pks.length > MAX_PK) return json({ error: `at most ${MAX_PK} pubkeys` }, 400);
- // one id per endpoint so re-registering replaces rather than duplicates
- const id = Bun.hash(sub_.endpoint).toString(36);
- const known = new Set(pks);
- for (const pk of pks) {
- regs[pk] = (regs[pk] || []).filter((r) => r.id !== id);
- regs[pk].push({ sub: sub_, id, updated: Date.now() });
- }
- // drop this device from pubkeys it no longer cares about
- for (const pk of Object.keys(regs)) {
- if (known.has(pk)) continue;
- const before = regs[pk].length;
- regs[pk] = regs[pk].filter((r) => r.id !== id);
- if (!regs[pk].length) delete regs[pk];
- if (before !== (regs[pk]?.length ?? 0)) { /* changed */ }
- }
- persist();
- resubscribe();
- return json({ ok: true, watching: pks.length });
- }
-
- if (url.pathname === '/register' && req.method === 'DELETE') {
- const body = await req.json().catch(() => null);
- if (!body?.endpoint) return json({ error: 'endpoint required' }, 400);
- const id = Bun.hash(body.endpoint).toString(36);
- for (const pk of Object.keys(regs)) {
- regs[pk] = regs[pk].filter((r) => r.id !== id);
- if (!regs[pk].length) delete regs[pk];
- }
- delete notifyRegs[id];
- persist();
- resubscribe();
- return json({ ok: true });
- }
-
- return json({ error: 'not found' }, 404);
- },
-});
-
-log(`nwc push notifier on :${server.port}`);
-log(` relays: ${RELAYS.join(' ')}`);
-log(` watching ${watchedPubkeys().length} service pubkey(s)`);
-resubscribe();
-setInterval(prune, 3600_000);
-// relays drop long-lived subs; re-arm periodically
-setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);
diff --git a/src/app.js b/src/app.js
index dae7b17..db1aad4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -11,7 +11,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
-import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -3829,10 +3829,18 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
- // a phone video is shrunk on the device first (see video-compress.js)
+ // a phone video is shrunk on the device first (see video-compress.js);
+ // a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
mediaProgress('shrink', 0);
- const { compressVideo } = await import('./video-compress.js');
+ const { compressVideo, streamVideo } = await import('./video-compress.js');
+ const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
+ if (stream) {
+ mediaProgress('upload', 0);
+ const url = await uploadStream(stream, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
+ }
+ mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
@@ -6612,18 +6620,29 @@ if (INTENT_URI) {
// The feed scrolls like a phone on a desktop. A mouse wheel's notch gives
// it a push that coasts and slows, so a few turns travel a few posts and
// quick turns stack up; a trackpad (which already coasts) is left alone.
-// And a mouse can grab the feed and flick it: drag up or down, let go while
-// moving and it keeps going. A drag that starts sideways is still a text
-// selection, and a drag that moved isn't a click on what it started on.
+// (A mouse drag used to grab the feed and flick it; it selects text again —
+// Adam, 2026-10-18: "i'd rather be able to select text normally".)
(() => {
if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
- const SCOPE = '.feed-page, .notes-feed';
+ // The wheel coasts whenever a feed or a thread is the page being scrolled
+ // — wherever the pointer happens to be: over the posts, the header, the
+ // rail, the wallet column beside the feed on a wide screen (Adam,
+ // 2026-10-16: "when my cursor is hovered over the right side bar or any
+ // part of the page that's not the feed, wheeling scrolls without
+ // coasting. can we make it coast no matter where my pointer is?") —
+ // except over something that takes the wheel itself: a text box, the
+ // chips strip, the lightbox, the pager, a box that scrolls on its own.
+ const SCOPE = '.feed-page, .notes-feed, .thread-page'; // the pages that coast (a thread scrolls the same as a feed)
const TAU = 325; // ms: how quickly a coast dies down (the phone's feel)
const VMAX = 16; // px/ms
const inScope = (t) => {
const el = t && t.closest ? t : t && t.parentElement;
- if (!el || !el.closest(SCOPE)) return false;
- if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
+ if (!el || !document.querySelector(SCOPE)) return false;
+ // (A video or an audio player is part of the feed: a notch over a clip
+ // coasts like one over the words — the wheel does nothing to a player
+ // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a
+ // video it doesn't initiate a coast, just a single notch".)
+ if (el.closest('input, textarea, select, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
// something inside that scrolls on its own (a caption, a list) keeps its wheel
for (let n = el; n && n !== document.body; n = n.parentElement) {
if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false;
@@ -6653,16 +6672,28 @@ if (INTENT_URI) {
if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
};
// ---- the wheel
- // A turn back against the coast catches the page at once and is a plain
- // notch from rest — no flight the other way, since the next couple of
- // notches after a reversal (CALM) don't wind the speed up, so a couple of
- // correcting notches travel a couple of notches; a spin that keeps going
- // winds up again from the third. (A spell of quarter-size "hunting"
- // notches ramping back up was tried here and felt like a stutter and a
- // slow patch; a 700 ms spell without wind-up after a reversal was tried
- // too and a spin begun inside it felt slow — Adam, 2026-10-16.)
- const CALM = 2; // notches after a reversal without wind-up
- let calmLeft = 0; // (lastDir/lastMoveAt: the tick's, above)
+ // A turn back against a page that is flying is a brake: that notch only
+ // eases the coast to a stop (a quick easing, not a dead halt), and goes
+ // nowhere the other way. The next notch the same way is a gentle start
+ // back — half a notch's travel at half the speed — and the ones after
+ // wind up as usual. So the reader flying past a post stops on the first
+ // turn back, creeps back on the second, and gets going on the third.
+ // Against a coast that is merely strolling, or one that only just died,
+ // there is nothing worth braking: that turn back is the gentle start
+ // itself. (A spell of quarter-size "hunting" notches ramping back up over
+ // five turns was tried here and felt like a stutter and a slow patch; a
+ // full notch then two without wind-up felt like an overshoot; a gentle
+ // push straight away from a flight still overshot — Adam, 2026-10-16:
+ // "the first click of the wheel should just stop the coasting/flying
+ // maybe with a quick easing rather than a sudden stop and then the next
+ // notch should start going slowly in reverse and the next ones should
+ // start speeding up".)
+ const REVERSE = 0.5; // of a notch: the gentle start back
+ const BRAKE_V = 1.2; // px/ms: flying, worth a brake (a notch from rest peaks near 0.9)
+ const BRAKE_TAU = 40; // ms: the brake's easing (a flight at 8 px/ms stops within ~300 px)
+ const BRAKE_FOR = 2500; // ms: how long after a brake the next notch that way is the gentle start
+ let brake = null; // { dir, until }: the turn back that braked, awaiting its gentle start
+ // (lastDir/lastMoveAt: the tick's, above)
let smoothUntil = 0; // a trackpad's events were just seen: hands off
window.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return;
@@ -6676,72 +6707,43 @@ if (INTENT_URI) {
const now = e.timeStamp;
// the way the page is going: its coast, or the coast that only just died
const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : 0;
- if (going && dir !== going) {
- // A reversal catches the old coast immediately. Discard its fractional
- // step and frame clock too: after a busy frame, applying the NEW velocity
- // to the OLD elapsed time made the first reverse step jump. Give the
- // notch a small response now, then coast the rest of its travel.
+ const gentle = () => {
+ // The gentle start back catches whatever coast is left immediately.
+ // Discard its fractional step and frame clock too: after a busy frame,
+ // applying the NEW velocity to the OLD elapsed time made the first
+ // reverse step jump. A small response now, then the rest of its
+ // (half) travel coasts.
stop();
- calmLeft = CALM;
- const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16);
+ brake = null;
+ const travel = px * 3 * REVERSE;
+ const first = dir * Math.min(16, Math.abs(travel / TAU) * 16);
window.scrollBy({ top: first, behavior: 'instant' });
lastDir = dir; lastMoveAt = now;
- coast((px * 3 - first) / TAU, TAU);
+ coast((travel - first) / TAU, TAU);
+ };
+ if (going && dir !== going && Math.abs(v) >= BRAKE_V) {
+ // flying: this notch only brakes — the coast eases out over a few
+ // frames (from now: the frames a busy thread missed don't travel first)
+ tau = BRAKE_TAU; acc = 0; last = performance.now();
+ brake = { dir, until: now + BRAKE_FOR };
+ } else if (going && dir !== going) {
+ gentle();
+ } else if (brake && brake.dir === dir && now < brake.until) {
+ gentle(); // the notch after a brake: the gentle start back
} else {
+ brake = null;
// A notch into a page already moving pushes harder: the faster it
// goes, the more each turn adds (up to 2.5x), so keeping the wheel
// turning winds the speed up instead of settling at a stroll. From
// rest a notch is the same push as ever.
- const gain = calmLeft-- > 0 ? 1 : 1 + Math.min(1.5, Math.abs(v) / 4);
+ const gain = 1 + Math.min(1.5, Math.abs(v) / 4);
coast(v + (px * 3 * gain) / TAU, TAU);
}
}, { passive: false });
// anything else that moves the page takes over from a coast
window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); });
window.addEventListener('touchstart', stop, { passive: true });
- // ---- grab and flick
- let drag = null;
- document.addEventListener('pointerdown', (e) => {
- if (raf) stop(); // a click catches a coasting page, like a finger does
- if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return;
- drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] };
- });
- document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out
- document.addEventListener('pointermove', (e) => {
- if (!drag) return;
- if (!(e.buttons & 1)) { drag = null; return; }
- if (drag.sel) return;
- if (!drag.on) {
- const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y);
- if (dx < 6 && dy < 6) return;
- // clearly sideways: selecting text (and still not a click on the post)
- if (dx > 2 * dy) { drag.sel = true; return; }
- drag.on = true;
- document.documentElement.classList.add('grab-scrolling');
- try { getSelection().removeAllRanges(); } catch {}
- }
- window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' });
- drag.ly = e.clientY;
- drag.pts.push([e.timeStamp, e.clientY]);
- while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift();
- });
- const end = (e) => {
- if (!drag) return;
- const d = drag; drag = null;
- if (!d.on && !d.sel) return;
- if (d.on) {
- document.documentElement.classList.remove('grab-scrolling');
- const p = d.pts, a = p[0], b = p[p.length - 1];
- // released while moving: throw it; held still first: just let go
- if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 2.4);
- }
- // the press moved: what it started on isn't opened (Chrome still clicks it)
- const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
- document.addEventListener('click', eat, { capture: true, once: true });
- setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 300);
- };
- document.addEventListener('pointerup', end);
- document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; });
+ document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does
})();
window.addEventListener('load', () => {
diff --git a/src/features/messages.js b/src/features/messages.js
index 20cfaa6..0c69f23 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -17,6 +17,7 @@ import {
npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS,
} from '../nostr.js';
+import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack
import {
channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
@@ -87,7 +88,7 @@ export function messagesFeature(ctx) {
const gifs = createGifPicker({ h, t, ui, render });
// NIP-92: an attachment described, so clients needn't sniff the URL
const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []),
- ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
+ ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
// ---- persisted state ----------------------------------------------------
@@ -462,7 +463,7 @@ export function messagesFeature(ctx) {
const feedsChanged = () => {
// a feed that takes in your lists is built from the other feeds: a
// changed list rebuilds it next time it's opened
- for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); }
+ for (const f of st().feeds) if (expands(f)) for (const k of [f.id, f.id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c && c !== feed) feedStates.delete(k); }
clearTimeout(feedsPush);
feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000);
};
@@ -688,23 +689,57 @@ export function messagesFeature(ctx) {
healSince = 0;
}
const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id);
- function rememberWrap(id) {
- const k = wrapKey(id);
- if (!wrapLog) wrapLog = [];
- if (!seenWraps.has(k)) wrapLog.push(k);
- seenWraps.add(k);
+ // The wraps this device holds — full id and time — for reconciliation
+ // (NIP-77) with a relay that speaks it: a subscribe then fetches only the
+ // wraps we lack instead of the relay's newest two hundred (half a
+ // megabyte, every boot and every resubscribe), and the live subscription
+ // opens with limit 0, nothing re-served. The prefix log above stays the
+ // "skip this one" memory; a wrap it knows but this list doesn't (logged
+ // before the list existed) is added as the relay re-serves it, once.
+ const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage)
+ let wrapItems = null; // id -> created_at, oldest first
+ function loadWrapItems() {
+ if (wrapItems) return;
+ wrapItems = new Map();
+ try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {}
+ }
+ function noteWrapItem(id, ts) {
+ if (!id || !ts) return;
+ loadWrapItems();
+ if (wrapItems.has(id)) return;
+ wrapItems.set(id, ts);
+ while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value);
+ scheduleWrapSave();
+ }
+ const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); };
+ function scheduleWrapSave() {
clearTimeout(wrapsTimer);
wrapsTimer = setTimeout(() => {
try {
// another tab may have logged wraps since this one loaded: keep theirs
const s = st();
- const mine = new Set(wrapLog);
- s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX);
- wrapLog = s.wraps;
+ if (wrapLog) {
+ const mine = new Set(wrapLog);
+ s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX);
+ wrapLog = s.wraps;
+ }
+ if (wrapItems) {
+ const all = new Map(s.wrapItems || []);
+ for (const [id, ts] of wrapItems) all.set(id, ts);
+ s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX);
+ }
save(s);
} catch {}
}, 1500);
}
+ function rememberWrap(id, ts) {
+ const k = wrapKey(id);
+ if (!wrapLog) wrapLog = [];
+ if (!seenWraps.has(k)) wrapLog.push(k);
+ seenWraps.add(k);
+ if (ts) noteWrapItem(id, ts);
+ scheduleWrapSave();
+ }
let dmStarted = false;
let allUnsubs = [];
let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt
@@ -712,11 +747,16 @@ export function messagesFeature(ctx) {
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
- repaintTimer = setTimeout(() => {
+ const run = () => {
+ // not under a pager in motion: the page beneath is out of sight, and
+ // its repaint (40–55 ms on Adam's phone) dropped frames from flights
+ // through the pictures (2026-10-17) — it waits for the landing
+ if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
- }, 80);
+ };
+ repaintTimer = setTimeout(run, 80);
};
// Profiles persist across sessions (capped) so known faces paint right
@@ -3055,13 +3095,14 @@ export function messagesFeature(ctx) {
async function handleInboxWrap(wrap) {
let heal = false;
if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) {
+ noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full
if (!healing(wrap)) return;
heal = true;
}
seenWraps.add(wrap.id);
const r = await openInboxWrap(wrap, heal);
if (r === 'stale') return;
- if (r === true) { rememberWrap(wrap.id); return; }
+ if (r === true) { rememberWrap(wrap.id, wrap.created_at); return; }
if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value);
pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 });
scheduleDrain(8000);
@@ -3101,9 +3142,9 @@ export function messagesFeature(ctx) {
if (complete && !decryptorsComplete()) break;
const r = await openInboxWrap(p.wrap, p.heal);
if (r === 'stale') break; // the account changed under this pass
- if (r === true) { pendingWraps.delete(id); rememberWrap(id); }
+ if (r === true) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); }
else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later
- else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); }
+ else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); }
if (performance.now() - chunkStart > 8) {
await new Promise((r) => setTimeout(r));
chunkStart = performance.now();
@@ -3160,12 +3201,42 @@ export function messagesFeature(ctx) {
}
bumpMsgRev();
if (swept) save(s);
- const dmSub = (relays) => {
- const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
- handleInboxWrap(wrap).catch(() => {});
- }, { onclose: noteSubClosed });
+ const onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); };
+ const plainDmSub = (relays) => {
+ const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed });
allUnsubs.push(u); dmUnsubs.push(u);
};
+ // Relay by relay. One that speaks negentropy gets the live subscription
+ // with limit 0 — opened first, so nothing slips between — and a
+ // reconciliation of the wraps we hold against its newest four hundred,
+ // the ones we lack fetched by id. One that doesn't (nos.lol has it
+ // disabled; remembered for a day) is asked the old way, its newest
+ // wraps re-served and skipped by the prefix log. So is every relay
+ // while the one-time heal is running: that pass wants them re-served.
+ const dmSub = (relays) => {
+ for (const relay of relays) {
+ if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; }
+ const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed });
+ allUnsubs.push(u); dmUnsubs.push(u);
+ reconcileWraps(relay).then((ok) => {
+ if (ok || !dmStarted || !dmUnsubs.includes(u)) return;
+ // it doesn't speak it after all: the plain subscription, with its backlog
+ try { u(); } catch {}
+ allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u);
+ plainDmSub([relay]);
+ }).catch(() => {});
+ }
+ };
+ async function reconcileWraps(relay) {
+ const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld());
+ if (!r) return false;
+ dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks');
+ for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) {
+ const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000);
+ for (const w of evs) onWrap(w);
+ }
+ return true;
+ }
dmSub(DM_RELAYS);
// Senders deliver to the relays our kind-10050 advertises — a list other
// clients may have published with relays beyond our defaults. Read those
@@ -4012,6 +4083,48 @@ export function messagesFeature(ctx) {
// replies must not crowd its likes and zaps out of it.
const REPLY_KINDS = [1, 1111];
const replyIds = new Map(); // note id -> Map(reply id -> author pubkey)
+ // The replies themselves, as the count fetch brought them — a thread's
+ // first page, in hand before it is opened: every post on screen has its
+ // replies asked for (watchZaps), so opening it paints the conversation at
+ // once and the relays only add to it. Bounded: the targets most recently
+ // heard from, a few dozen replies each.
+ const replyEvents = new Map(); // note id -> Map(reply id -> event)
+ const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60;
+ function keepReply(target, ev) {
+ let m = replyEvents.get(target);
+ if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last
+ if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); }
+ replyEvents.set(target, m);
+ while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value);
+ }
+ // The replies in hand for a thread: those naming the root, and those
+ // naming only the reply they answer (one hop down), oldest first.
+ function repliesInHand(rootId) {
+ const out = new Map();
+ const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); };
+ take(rootId);
+ for (const rid of [...out.keys()]) take(rid);
+ return [...out.values()].sort((a, b) => a.created_at - b.created_at);
+ }
+ // …into a thread's cache entry: on opening it, and again when the count
+ // fetch answers for a thread fetched ahead before the answer came
+ function adoptRepliesInHand(c) {
+ const have = new Set(c.replies.map((e) => e.id));
+ let added = [];
+ for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); }
+ if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); }
+ return added.length;
+ }
+ // A thread's rows painted in their final shape: the faces, pictures,
+ // previews and quotes of what is in hand are fetched ahead (noteReady, as
+ // the feed does per row), so an opened thread doesn't shift as they land.
+ const threadWarmed = new WeakSet();
+ const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face
+ function warmThread(evs, deadline = Date.now() + READY_MS) {
+ const fresh = evs.filter((e) => e && !threadWarmed.has(e));
+ for (const e of fresh) threadWarmed.add(e);
+ return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {})));
+ }
const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> })
// Resolve from this reaction's own tag: packs can reuse the same shortcode
// for different pictures, and an unrelated event must not change its face.
@@ -4381,6 +4494,7 @@ export function messagesFeature(ctx) {
let m = replyIds.get(x[1]);
if (!m) replyIds.set(x[1], m = new Map());
if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; }
+ keepReply(x[1], ev);
}
if (changed) scheduleRepaint();
}
@@ -4443,6 +4557,8 @@ export function messagesFeature(ctx) {
noteCountsReady.add(id);
if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false);
}
+ // the open thread takes the replies this answer brought
+ if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable();
if (c?.root) persistThread(c);
saveZapTotals();
scheduleRepaint();
@@ -4708,6 +4824,41 @@ export function messagesFeature(ctx) {
const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts');
// what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment
const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e));
+ // A conversations feed (Amethyst's "Conversations" beside its "New
+ // Threads"): the replies these people write, each under the post it
+ // answers, instead of their posts. Asked for as notes and NIP-22 comments;
+ // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.)
+ const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS);
+ const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e));
+ // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root'
+ // (a reply straight to the root), else — the deprecated positional form —
+ // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E.
+ function parentRefOf(ev) {
+ if (!ev || !replyish(ev)) return null;
+ const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]);
+ let tag;
+ if (ev.kind === 1111) tag = es[0] || (ev.tags || []).find((x) => x[0] === 'E' && x[1]);
+ else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1];
+ if (!tag) return null;
+ const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] };
+ if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase();
+ return ref;
+ }
+ // A note already in hand — in a feed, a thread, someone's page — before
+ // asking any relay for it: the post a reply answers is usually right
+ // there in the Following feed.
+ function noteInHand(id) {
+ for (const c of feedStates.values()) for (const e of c.notes) {
+ if (e.id === id) return e;
+ if (e.kind === REPOST) { const inner = repostInner(e); if (inner && inner.id === id) return inner; }
+ }
+ for (const c of threadCache.values()) {
+ if (c.root && c.root.id === id) return c.root;
+ for (const e of c.replies || []) if (e.id === id) return e;
+ }
+ for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e;
+ return null;
+ }
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) {
@@ -4775,6 +4926,7 @@ export function messagesFeature(ctx) {
// DOWN, and that must not count as having gone up to look at them.
const y = window.scrollY || 0, up = y < lastFeedY;
lastFeedY = y;
+ if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert)
// Scrolling recounts the pill: posts you've gone up past drop off it,
// and once none are left above you the notice is done.
if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => {
@@ -4787,7 +4939,7 @@ export function messagesFeature(ctx) {
else if (n !== feed.pillN) render();
});
if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed);
- if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : Math.max(2400, window.innerHeight * 3))) return;
+ if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
else loadOlderFeed().catch(() => {});
}, { passive: true });
@@ -5177,6 +5329,13 @@ export function messagesFeature(ctx) {
// Two feeds everyone has: the people you follow, and everything the coinos
// relay carries. Each remembers its own relay choice in the state.
const EVERYTHING = 'all';
+ // The Conversations view of a feed (the chip beside Notes / Images /
+ // Videos / Articles): the same people, their replies — a feed state of
+ // its own under the feed's id with this suffix, its definition derived
+ // from the feed's (replies: true, see feedKeeps). Nothing is stored
+ // under it but its notes cache.
+ const VIEW_SUFFIX = '~replies';
+ const viewId = (id = curFeedId) => (mediaMode === 'replies' ? id + VIEW_SUFFIX : id);
// Amethyst's feed model, for the people who asked: everyone you follow plus
// everyone on your lists, and on top of that posts from anyone on a
// hashtag you follow — your topic feeds' tags and your NIP-51 interests
@@ -5233,6 +5392,10 @@ export function messagesFeature(ctx) {
...st().feeds.map(expandFeed)];
};
function feedDef(id = curFeedId) {
+ if (typeof id === 'string' && id.endsWith(VIEW_SUFFIX)) {
+ const base = feedDef(id.slice(0, -VIEW_SUFFIX.length));
+ return base ? { ...base, id, replies: true, view: true } : null;
+ }
if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null;
const own = st().feeds.find((f) => f.id === id);
return own ? expandFeed(own) : adhocFeeds.get(id) || null;
@@ -5845,15 +6008,16 @@ export function messagesFeature(ctx) {
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = EVERYTHING; // a feed deleted on another device
- let c = feedStates.get(curFeedId);
+ const id = viewId(); // the feed, or its Conversations view
+ let c = feedStates.get(id);
if (!c) {
let stored = [];
- try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
+ try { stored = feedDisk()?.read(id) || wallet.loadFeatureState(feedCacheKey(id), []) || []; } catch {}
if (!Array.isArray(stored)) stored = [];
- if (!stored.length) stored = borrowedNotes(feedDef(curFeedId));
+ if (!stored.length) stored = borrowedNotes(feedDef(id));
stored = stored.slice(0, FEED_STORE);
- c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
- feedStates.set(curFeedId, c);
+ c = { id, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
+ feedStates.set(id, c);
while (feedStates.size > 4) {
const oldest = feedStates.keys().next().value;
feedStates.get(oldest).stopped = true;
@@ -5862,14 +6026,14 @@ export function messagesFeature(ctx) {
feed = c;
// Only the opening page gates first paint. The next page warms on the
// side, so an offscreen image cannot delay the whole feed.
- c.boot = notesReady(stored.slice(0, FEED_PAGE)).finally(() => {
+ c.boot = notesReady(stored.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && ui.chatOpen && ui.msgView === 'feed' && c === feed) scheduleRepaint();
});
c.boot.then(() => {
if (c.stopped) return;
- notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2));
+ notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2), { parent: feedReplies(c) });
});
prefetchFeed(c);
}
@@ -5913,8 +6077,8 @@ export function messagesFeature(ctx) {
const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def));
if (!authors.size && !topics.size) return [];
const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1])));
- const fits = (e) => ((!authors.size || authors.has(e.pubkey))
- && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT));
+ const fits = (e) => feedKeeps(def, e) && (((!authors.size || authors.has(e.pubkey))
+ && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT)));
const seen = new Set(), out = [];
for (const o of feedStates.values()) {
for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); }
@@ -5936,10 +6100,10 @@ export function messagesFeature(ctx) {
if (!authors.length && !topics.length) continue;
try {
const events = (await Promise.all((authors.length ? authorChunks(authors) : [null]).map((chunk) =>
- queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2,
+ queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: feedKinds(def), limit: FEED_SEED_POSTS * 2,
...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000)))).flat();
if (feedStates.has(def.id)) continue; // opened while we were asking
- disk.seed(def.id, events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)));
+ disk.seed(def.id, events.filter((e) => feedKeeps(def, e) && !hidden(e)));
} catch {}
}
}
@@ -6048,7 +6212,7 @@ export function messagesFeature(ctx) {
if (!def) return null;
const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : '';
if (def.all) return (def.curated ? '/feed' : '/feed/all') + r;
- if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null;
+ if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null;
if (def.of) return '/feed/' + npubOf(def.of) + r;
const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || [];
if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r;
@@ -6071,7 +6235,9 @@ export function messagesFeature(ctx) {
// beside it). Changing it rebuilds the feed from those relays.
// Forget a feed's state so it is rebuilt; a fetch still in flight for the
// old one lands nowhere (it would otherwise fill the orphan, not the new).
- const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); };
+ const dropFeedState = (id) => {
+ for (const k of [id, id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c) c.stopped = true; feedStates.delete(k); }
+ };
// Popular's defaults before 2026-10-04: a list saved that matches them
// exactly was the defaults, kept by an edit, and follows the new ones.
function oldPopularDefault(list) {
@@ -6305,21 +6471,23 @@ export function messagesFeature(ctx) {
// publish it); otherwise the metadata alone is fetched — a few KB for a
// moov-first file — with a short cap, since it gates the row. Remembered
// on disk so a clip seen once is never probed again.
- const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i;
+ const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does)
// files known to be clips without saying so in their name (divine.video's
// have no extension): an imeta tag's video/* type, or a video event's file
const videoUrls = new Set();
const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url);
const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; };
- // A video event's file: of the versions its imeta tags offer, the one
- // nearest 1080 on its short side (a phone's screen; not the 4K original),
- // never an HLS playlist; else its url tag.
+ // A video event's file: its HLS stream where the browser plays one (the
+ // player picks the rendition); else of the versions its imeta tags
+ // offer, the one nearest 1080 on its short side (a phone's screen; not the
+ // 4K original); else its url tag.
function videoEventFile(ev) {
- let best = null;
+ let best = null, stream = null;
for (const tag of ev.tags || []) {
if (tag[0] !== 'imeta') continue;
const f = imetaFields(tag);
- if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue;
+ if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; }
+ if (!/^https?:\/\//.test(f.url || '')) continue;
if (f.m && !/^video\//.test(f.m)) continue;
const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || '');
const edge = dm ? Math.min(+dm[1], +dm[2]) : 0;
@@ -6327,6 +6495,7 @@ export function messagesFeature(ctx) {
if (!best || score > best.score) best = { url: f.url, image: f.image, score };
}
if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; }
+ if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) };
if (best) videoUrls.add(best.url);
return best;
}
@@ -6444,7 +6613,15 @@ export function messagesFeature(ctx) {
// an iPhone's HEVC (hvc1/hev1 in the sample description)
let hevc = false;
for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true;
- return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) };
+ // its length, from the movie header (a long clip plays as HLS: playSrc)
+ let dur = 0;
+ for (let j = 4; j + 32 <= b.length && !dur; j++) {
+ if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm'
+ const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16));
+ const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20);
+ if (scale && units && units !== 0xffffffff) dur = units / scale;
+ }
+ return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) };
}
const videoProbes = new Map();
let videoProbing = 0;
@@ -6472,6 +6649,7 @@ export function messagesFeature(ctx) {
}
}
async function runVideoProbe(url) {
+ if (isHls(url)) return probeStream(url);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10_000);
try {
@@ -6498,7 +6676,7 @@ export function messagesFeature(ctx) {
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
- const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) };
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
if (total > 0) d.size = total;
else {
// a server that hides Content-Range from scripts (Primal's) still
@@ -6517,6 +6695,25 @@ export function messagesFeature(ctx) {
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
+ // A stream is measured by a player: its playlist says nothing of the
+ // picture's size. Where HLS doesn't play, it can't be measured either.
+ async function probeStream(url) {
+ if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); }
+ const v = document.createElement('video');
+ v.muted = true; v.preload = 'metadata';
+ const ok = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(false), 15_000);
+ v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); };
+ v.onerror = () => { clearTimeout(timer); resolve(false); };
+ v.src = url;
+ });
+ if (ok) {
+ noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) });
+ rememberVideo(url, v.videoWidth, v.videoHeight);
+ } else noteVideoInfo(url, { failedAt: Date.now() });
+ v.removeAttribute('src'); v.load();
+ return videoInfo.get(url);
+ }
// How much a clip may cost before it waits for a tap: less on a slow or
// metered connection.
const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } };
@@ -6531,16 +6728,28 @@ export function messagesFeature(ctx) {
// Data Saver on, or a slow link: the connection to be careful with
const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } };
const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs
- function videoHeavy(url) {
- if (tappedClips.has(url) || largeVideosOk()) return false;
+ // A clip too big to play as it is: over the budget, past 1080p, its index
+ // at the end of a big file — or (known) unmeasurable. Such a clip plays as
+ // an HLS stream where it can (playSrc); else it waits for a tap.
+ function bigClip(url, known = false) {
const d = videoInfo.get(url) || {};
if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true;
- if (!d.size) return true; // nothing known: don't spend data blind
+ if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind
if (d.size > videoBudget()) return true;
// the whole file before the first frame: only worth it for a small one
if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true;
return false;
}
+ function videoHeavy(url) {
+ if (tappedClips.has(url) || largeVideosOk()) return false;
+ const d = videoInfo.get(url) || {};
+ if (d.stream) return false; // a stream takes the rendition the connection carries
+ // one that streams costs what the connection carries: it plays like any
+ // other (Adam, 2026-10-18) — but not over Data Saver or a slow link,
+ // where even a stream is the reader's call
+ if (streamsHere(url) && !frugalData()) return false;
+ return bigClip(url);
+ }
const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
function heavyLabel(url) {
const d = videoInfo.get(url) || {};
@@ -6685,12 +6894,21 @@ export function messagesFeature(ctx) {
await warmMedia(avatarUrl(p, pk));
if (p?.picture) makeThumb(pk, p);
}
- async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
+ // parent: the post a reply answers is part of the row (a conversations
+ // feed), so it arrives with the row; elsewhere a reply's parent is fetched
+ // only where it is shown (a profile's Replies tab fills its cards in).
+ async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0, { parent: withParent = false } = {}) {
if (ev && ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return; ev = inner; }
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })),
...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
+ // a reply's parent, shown above it where replies are shown with context
+ const parent = withParent && !depth ? parentRefOf(ev) : null;
+ if (parent) {
+ const quote = quotedNote(parent);
+ tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1)));
+ }
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, ''));
@@ -6708,7 +6926,7 @@ export function messagesFeature(ctx) {
await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]);
} catch {} finally { clearTimeout(timer); }
}
- const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); };
+ const notesReady = (evs, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); };
const keyed = (node, key) => { node.setAttribute('data-key', key); return node; };
// Keep only the resources this row actually uses. Copying the entire
// session media cache into every row grows quadratically as you scroll.
@@ -6717,6 +6935,9 @@ export function messagesFeature(ctx) {
if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null);
return feedPaint.media.get(url);
}
+ // a conversations feed: each reply under the post it answers (remembered
+ // on the feed's state; a changed definition rebuilds the state)
+ const feedReplies = (c) => (c.replies ??= !!(feedDef(c.id) || {}).replies);
function feedRow(c, ev) {
let presentation = c.presentations.get(ev.id);
if (!presentation) {
@@ -6726,7 +6947,7 @@ export function messagesFeature(ctx) {
const prev = feedPaint;
feedPaint = presentation;
try {
- const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id);
+ const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id);
if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); }
return row;
}
@@ -6853,10 +7074,10 @@ export function messagesFeature(ctx) {
const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); };
const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); };
const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } };
- let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e)
+ const def = feedDef(c.id);
+ let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e)
&& fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id));
if (!add.length) return false;
- const def = feedDef(c.id);
if (def && def.curated) {
const kept = curateFeed(add);
for (const e of add) if (!kept.includes(e)) unstage(e);
@@ -6871,7 +7092,7 @@ export function messagesFeature(ctx) {
c.booting = true;
for (const e of add) unstage(e);
admitFeed(add, c);
- c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => {
+ c.boot = notesReady(c.notes.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && c === feed) scheduleRepaint();
@@ -6882,7 +7103,7 @@ export function messagesFeature(ctx) {
// Each row waits only for its own presentation. A slow photo or quote
// cannot hold ready text posts (or their new-post notice) behind it.
const ready = await Promise.all(add.map(async (e) => {
- await noteReady(e);
+ await noteReady(e, undefined, 0, { parent: feedReplies(c) });
unstage(e);
if (c.stopped) return false;
// Cached rows can still be warming. This new row is already prepared
@@ -6945,7 +7166,7 @@ export function messagesFeature(ctx) {
}
const result = mergeFeedWindow(c.notes, c.shown, add, {
rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })),
- height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE,
+ height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE * (1 + APPETITE_MAX), // room for the pass fetched ahead
holdHead: headBusy(),
});
c.deferred = result.deferred;
@@ -7132,23 +7353,25 @@ export function messagesFeature(ctx) {
}, maxWait);
return got;
}
- async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) {
+ // (relays: asked in place of the feed's own — the archives, for history
+ // the feed's relays have let go)
+ async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT, relays = null) {
const def = feedDef(c.id);
if (!def) return false;
+ if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies
const authors = feedAuthors(def), topics = feedTopics(def);
const tag = topics.length ? { '#t': topics } : {};
let got = false;
if (def.curated) return popularPass(extra, merge, c, def);
const orT = orTopicsOf(def);
const orPass = orT.length
- ? queryFeed(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; })
+ ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; })
: null;
if (!authors.length) {
// topics alone: nobody's outbox to read, so the wide relays, one ask;
// the firehose asks its relays for everything
if (!topics.length && !def.all) return false;
- const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
- return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c);
+ return queryFeed(relays || feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, limit, ...extra }, merge, c);
}
const readPlan = async (plan) => {
await Promise.all(plan.flatMap(({ relays, authors: a }) => {
@@ -7161,7 +7384,7 @@ export function messagesFeature(ctx) {
});
}));
};
- if (feedRelays(def)) await readPlan([{ relays: feedRelays(def), authors }]);
+ if (relays || feedRelays(def)) await readPlan([{ relays: relays || feedRelays(def), authors }]);
else {
const initial = outboxPlan(authors);
const reading = readPlan(initial);
@@ -7362,22 +7585,23 @@ export function messagesFeature(ctx) {
// the same relays the pass above reads, one subscription each.
function watchFeed() {
stopFeedWatch();
- const def = feedDef();
- if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const c = feedNow();
+ const def = feedDef(c.id); // the Conversations view has its own
+ if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const newerThan = c.notes.length ? c.notes[0].created_at : Infinity;
const since = Math.floor(Date.now() / 1000) - 60;
const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {};
+ const kinds = feedKinds(def);
const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; }
- if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; }
+ if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; }
const orT = orTopicsOf(def);
- if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, '#t': orT, since }, on));
+ if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on));
const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
for (const { relays, authors: a } of plan)
for (let i = 0; i < a.length; i += REQ_AUTHORS)
- feedUnsubs.push(subscribeOn(relays, { kinds: topics.length ? FEED_KINDS : [...FEED_KINDS, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
+ feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
@@ -7391,43 +7615,98 @@ export function messagesFeature(ctx) {
feedAheadTimer = setTimeout(() => {
feedAheadTimer = null;
if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return;
- if (mediaMode !== 'all') {
+ if (!listView()) {
// a grid holds only the posts with media: keep fetching while its
// foot is near (within reason — a firehose has no end)
const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid');
if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {});
return;
}
+ fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin
if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging
const rows = document.querySelectorAll('.notes-feed > .row[data-key]');
const last = rows[rows.length - 1];
- if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {});
+ if (last && last.getBoundingClientRect().bottom < feedLead()) loadOlderFeed().catch(() => {});
}, 100);
}
+ // ---- the reader's appetite -------------------------------------------
+ // How hungrily the feed is being scrolled: the screens travelled, by the
+ // reader's own hand, over the last ten seconds. A page of posts is read
+ // at a screen or two in that time; five screens in it is someone flying
+ // through, and the feed answers by paging more posts at a time and
+ // asking for them from further up — up to four pages and four times the
+ // lead at once. (Adam, 2026-10-16: "could we load posts faster if it
+ // seems like the user is scrolling a lot? … try always loading more
+ // posts and loading them sooner when we're further away from the
+ // bottom".) The trail counts only scrolls within a moment of an input,
+ // so a page held still under an insert above doesn't look like travel.
+ const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5;
+ const scrollTrail = []; // [at, scrollY]
+ function noteScrollTrail(y) {
+ const now = Date.now();
+ scrollTrail.push([now, y]);
+ while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift();
+ }
+ function appetite() {
+ const now = Date.now();
+ while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift();
+ if (scrollTrail.length < 2) return 1;
+ const H = (typeof window !== 'undefined' && window.innerHeight) || 800;
+ const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1];
+ return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H))));
+ }
+ // how far above the foot of the feed the next page is asked for
+ const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite();
+ // The relays run AHEAD of the rows. The older page used to be asked for
+ // only once every post in hand was on screen, and shown only once the
+ // whole pass had answered — so the reader who reached the end of what we
+ // held waited a relay round trip, up to the five seconds the slowest
+ // relay in the pass is given. Now a pass for older posts starts as soon
+ // as what we hold beyond the shown rows thins to a page or two (more
+ // with the appetite), its rows warming at the door while the reader is
+ // still on the posts before them; the showing step takes from hand.
+ function fetchOlderAhead(c) {
+ if (c.fetching) return c.fetching;
+ if (c.end || c.booting || c.stopped || c.status !== 'ready') return null;
+ if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null;
+ const oldest = c.notes.at(-1);
+ if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; }
+ c.fetching = (async () => {
+ let until = oldest.created_at - 1;
+ const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip
+ for (let tries = 0; tries < 3; tries++) {
+ c.sawAny = 0;
+ if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true;
+ // nothing older at all (or no step back): that really is the end
+ if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; }
+ until = c.seenBack - 1;
+ }
+ return false;
+ })().catch(() => false).finally(() => {
+ c.fetching = null;
+ if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); }
+ });
+ return c.fetching;
+ }
async function loadOlderFeed() {
const c = feedNow();
if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
c.loadingMore = true;
if (c === feed) render(); // the footer says so while it happens
try {
- if (c.shown < c.notes.length) {
- const end = Math.min(c.shown + FEED_PAGE, c.notes.length);
- const boundary = c.notes[end - 1].id;
- await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)));
- c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
- return;
- }
- if (c.status !== 'ready' || c.end) return;
- const oldest = c.notes.at(-1);
- if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
- let until = oldest.created_at - 1;
- for (let tries = 0; tries < 3; tries++) {
- c.sawAny = 0;
- if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; }
- // nothing older at all (or no step back): that really is the end
- if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; }
- until = c.seenBack - 1;
+ fetchOlderAhead(c);
+ if (c.shown >= c.notes.length) {
+ // nothing in hand: the pass underway (started just now, if need be) brings the next page
+ if (!c.fetching) return;
+ await c.fetching;
+ if (c.shown >= c.notes.length) return;
}
+ const page = FEED_PAGE * appetite();
+ const end = Math.min(c.shown + page, c.notes.length);
+ const boundary = c.notes[end - 1].id;
+ await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) });
+ c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
+ fetchOlderAhead(c); // and the relays, if that thinned what we hold
} finally {
c.loadingMore = false;
if (c === feed) render();
@@ -7551,6 +7830,9 @@ export function messagesFeature(ctx) {
const vmediaOk = (url) => /^https:\/\//i.test(url || '');
// v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served)
const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined);
+ // the clip's very first frame at full size (the pager's slides ahead hold it)
+ const serverFirst = (url) => (vmediaOk(url) ? VMEDIA + 'first.jpg?u=' + encodeURIComponent(url) : undefined);
+ const serverFirstFailed = new Set(); // clips the server had no first frame for (this session)
const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url);
const noPicture = new Set(), h264Failed = new Set();
const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session)
@@ -7599,10 +7881,32 @@ export function messagesFeature(ctx) {
const canHevc = () => hevcPlays ??= (() => {
try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
})();
- // what a player for this clip loads: the copy, once the original is known
- // not to show here
- const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url)
- && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
+ // A long clip (or a big one whose length isn't known) plays as HLS from
+ // vmedia: a 360p and a 720p rendition, the player taking the one the
+ // connection carries, in 4-second pieces made as they're watched — not a
+ // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you
+ // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS
+ // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the
+ // original as before.
+ const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6;
+ const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url);
+ const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || '');
+ const hlsFailed = new Set();
+ let hlsPlays;
+ const canHls = () => hlsPlays ??= (() => {
+ try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; }
+ })();
+ const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; };
+ // Long, or too big to play as it is (a 4K clip, a 140 MB one: these
+ // waited for a tap with their cost on them) — streamed at a rendition the
+ // connection carries instead. A small short clip plays as it is: one
+ // request, its own quality, no work for the server.
+ const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true));
+ // what a player for this clip loads: the stream for a long or big one;
+ // the copy, once the original is known not to show here
+ const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url
+ : streamsHere(url) ? hlsOf(url)
+ : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
// A player whose clip turns out to have no picture it can show (its
// metadata says zero width) switches to the copy; if the copy fails too,
// the original's sound plays under a line saying why, with the file.
@@ -7618,6 +7922,14 @@ export function messagesFeature(ctx) {
});
v.addEventListener('error', () => {
if (!onCopy()) return;
+ // the stream failed: the copy or the original, as it would have been
+ if (isHls(v.getAttribute('src'))) {
+ hlsFailed.add(url);
+ const want = !v.paused;
+ v.setAttribute('src', playSrc(url));
+ if (want) v.play().catch(() => {});
+ return;
+ }
h264Failed.add(url);
const want = !v.paused;
v.setAttribute('src', url);
@@ -7948,7 +8260,11 @@ export function messagesFeature(ctx) {
function quotedNote(ref) {
let c = quoted.get(ref.id);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
- if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
+ if (!c) {
+ const have = noteInHand(ref.id);
+ if (have) { c = { status: 'ready', ev: have, at: Date.now(), promise: Promise.resolve() }; quoted.set(ref.id, c); return c; }
+ c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c);
+ }
c.at = Date.now();
c.promise = (async () => {
c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
@@ -9945,13 +10261,16 @@ export function messagesFeature(ctx) {
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
- function noteRow(pk, ev, name, { open = true, focus = false, joined = false } = {}) {
+ // context: a reply shows the post it answers above itself (the
+ // Conversations feed, a profile's Replies tab) — a quote card, which opens
+ // that post; the reply's own tap opens the thread at the reply.
+ function noteRow(pk, ev, name, { open = true, focus = false, joined = false, context = false } = {}) {
if (ev.kind === REPOST) {
const inner = repostInner(ev);
if (!inner) return h('div', { class: 'row note-post', style: 'display:none' });
const prev = repostBy;
repostBy = ev.pubkey;
- try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined }); }
+ try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined, context }); }
finally { repostBy = prev; }
}
// who reposted this, for its header line (only this row: a quote inside draws none)
@@ -9976,6 +10295,7 @@ export function messagesFeature(ctx) {
const foreign = ev.kind !== 1 && ev.kind !== ARTICLE && ev.kind !== POLL && ev.kind !== COMMENT && !isVideoEvent(ev) && !zapRec;
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e'));
+ const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null;
const canZap = canZapPk(pk);
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
@@ -10015,6 +10335,9 @@ export function messagesFeature(ctx) {
h('div', { class: 'note-content' },
reposter ? h('div', { class: 'small faint note-reposted', onClick: (e) => { e.stopPropagation(); openProfile(reposter); } },
h('span', { 'aria-hidden': 'true' }, '\u{1F501} '), t('postRepostedBy', { name: displayName(reposter) })) : null,
+ parent ? h('div', { class: 'note-context', 'data-context-of': parent.id },
+ h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')),
+ quoteCard(parent, 0)) : null,
h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' },
h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
h('span', {
@@ -10024,7 +10347,7 @@ export function messagesFeature(ctx) {
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
- (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (edited ? ' · ' + t('postEditedTag') : ''))),
pending ? null : h('button', {
// the overflow stays up here; reply, boost, quote, react and zap
@@ -10138,6 +10461,7 @@ export function messagesFeature(ctx) {
.find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId));
if (c) {
threadCache.set(requestedRootId, c);
+ if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c);
if (!prefetch) {
c.focusId = seed.id;
const wasPrefetched = c.prefetched;
@@ -10171,9 +10495,15 @@ export function messagesFeature(ctx) {
for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() });
const rootId = stored?.rootId || requestedRootId;
c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, prefetched: prefetch,
- root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} };
+ // a root already in hand (the post a conversation row answers sits in
+ // the Following feed) paints at once; the relays only fill the replies
+ root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} };
+ // the replies the feed already fetched for the count go in now: the
+ // thread opens whole for any post that has been on screen
+ adoptRepliesInHand(c);
if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed);
c.replies.sort((a, b) => a.created_at - b.created_at);
+ warmThread([c.root, ...c.replies]);
threadCache.set(requestedRootId, c);
threadCache.set(rootId, c);
if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId;
@@ -10189,16 +10519,34 @@ export function messagesFeature(ctx) {
return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12);
};
let relays = await relaysFor(c.root || seed);
- const [roots, replies] = await Promise.all([
- c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll
+ // Replies go in as they arrive, the page repainted in place (not
+ // after the slowest relay's EOSE, which used to hold the whole
+ // thread behind a spinner); the root the moment it is found.
+ const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId);
+ let paintT = null;
+ const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); };
+ const got = new Set(c.replies.map((e) => e.id));
+ const streamed = new Map();
+ const take = (e) => {
+ if (!e || e.id === c.rootId || got.has(e.id)) return;
+ got.add(e.id); noteForSpam(e); streamed.set(e.id, e);
+ // painted whole: its face and pictures first (briefly), then the row
+ warmThread([e], Date.now() + THREAD_ROW_MS).then(() => {
+ if (c.replies.some((x) => x.id === e.id)) return;
+ c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at);
+ paint();
+ });
+ };
+ await Promise.all([
+ c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll
+ if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); }
+ }),
// kind 1 replies, and NIP-22 comments naming it as their root
- Promise.all([
- queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []),
- queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []),
- ]).then(([a, b]) => [...(a || []), ...(b || [])]),
+ queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}),
+ queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}),
]);
- if (!c.root) c.root = (roots || [])[0] || null;
- let all = [...c.replies, ...(replies || [])];
+ clearTimeout(paintT); paintT = null;
+ let all = [...c.replies, ...streamed.values()];
// A client that tags only the note it answered leaves the root
// unnamed: what we resolved as the root is itself a reply. Climb —
// fetch ITS root and re-root the thread there — a few hops at most.
@@ -10562,8 +10910,10 @@ export function messagesFeature(ctx) {
}
};
walk(c.rootId, 0);
- if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })));
- else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
+ // No spinner: the post reads at once and its replies come in whole as
+ // they arrive (Adam: rather have them pop in than wait to learn there
+ // are none); "no replies" only once the relays have spoken.
+ if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
if (!boxPlaced) kids.push(replyBox(false));
// A tapped reply deep in a long thread has to be ON SCREEN when the
// thread opens — highlighting it somewhere below the fold reads as the
@@ -11089,7 +11439,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:8px' },
tabs,
h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab },
- ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
@@ -11117,11 +11467,13 @@ export function messagesFeature(ctx) {
if (!file || !upload) return;
ui.postUploading = true; render();
try {
- const url = await upload(file);
+ // (a URL, or what a stream's upload says of it: its type, size, length)
+ const got = await upload(file);
+ const url = typeof got === 'string' ? got : got.url;
// A post canceled while its upload was in flight must stay canceled.
// `false` lets that composer reject the late result without affecting
// the reply composer, which shares this helper.
- if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' });
+ if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got });
} catch (e) { toast(e.message || String(e)); }
ui.postUploading = false; render();
}
@@ -11465,7 +11817,10 @@ export function messagesFeature(ctx) {
// it), built outside the morph so a repaint never restarts a playing clip.
const MEDIA_MODE_KEY = 'coinos-feed-media';
let mediaMode = 'all';
- try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {}
+ try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {}
+ // Notes and Conversations are lists of posts (the feed's own state, or its
+ // Conversations view); the rest are grids read from the media index.
+ const listView = () => mediaMode === 'all' || mediaMode === 'replies';
const mediaKey = (it) => it.ev.id + '|' + it.url;
// the picture an imeta tag names for a clip (NIP-92 `image`), if any
const posterOf = (ev, url) => {
@@ -11490,7 +11845,7 @@ export function messagesFeature(ctx) {
const key = mediaIdxKey(feedId);
let r = mediaIdx.get(key);
if (r) return r;
- r = { key, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
+ r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -11501,17 +11856,13 @@ export function messagesFeature(ctx) {
if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until;
if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end;
}
- // the bottom of a feed is looked for again after a day (a relay
- // added, a follow back-filled)
- // (only an end confirmed by repeated empty answers: one empty
- // answer was a timeout as often as the bottom, and held for a day)
- if (rec.end && rec.endSure && Date.now() - rec.end < 86_400_000) r.end = r.end || rec.end;
+ // (the bottom of a feed isn't kept: see crawlMedia)
}
// kept frames first, so the first paint of the grid is pictures
await Promise.all([warmVideoThumbs([...r.videos.values()]),
warmTileThumbs([...r.images.values()].flatMap((e) => noteMediaUrls(e.content)))]);
r.loaded = true;
- if (mediaMode !== 'all') scheduleRepaint();
+ if (!listView()) scheduleRepaint();
});
return r;
}
@@ -11520,7 +11871,7 @@ export function messagesFeature(ctx) {
r.saveT = setTimeout(() => {
const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at);
saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles),
- art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end });
+ art: { until: r.art.until, end: r.art.end }, until: r.until });
}, 1500);
}
// file the posts that carry media; each post is looked at once
@@ -11529,7 +11880,7 @@ export function messagesFeature(ctx) {
for (const e of evs) {
if (r.checked.has(e.id)) continue;
r.checked.add(e.id);
- if (!FEED_KINDS.includes(e.kind) || isReply(e)) continue;
+ if (r.replies ? !replyish(e) : !FEED_KINDS.includes(e.kind) || isReply(e)) continue;
for (const kind of ['images', 'videos']) if (!r[kind].has(e.id) && hasMediaOf(e, kind)) { r[kind].set(e.id, e); n++; }
}
if (n) saveMediaIdx(r);
@@ -11544,6 +11895,10 @@ export function messagesFeature(ctx) {
if (!def || !feedHasQuery(def)) return Promise.resolve();
if (def.curated) return loadOlderFeed(); // a curated feed pages its own way
const r = mediaIndex(c.id);
+ // A grid is never at its end (Adam, 2026-10-17: "I should never be all
+ // caught up"): the bottom it found — the relays answering empty — is
+ // looked past again a few minutes on.
+ if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; }
if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve();
mediaCrawling = (async () => {
scheduleRepaint();
@@ -11560,9 +11915,13 @@ export function messagesFeature(ctx) {
noteForSpam(ev);
indexMedia(r, [ev]);
};
- try { await feedPass({ until, limit: 100 }, { sink }, c); } catch {}
+ try { await feedPass({ until, limit: 100 }, { sink }, c, FEED_KINDS, FEED_LIMIT, r.deep ? TOPIC_ARCHIVE_RELAYS : null); } catch {}
if (c.stopped) break;
if (saw) { r.empty = 0; r.until = Math.min(oldest, until); continue; }
+ // Nothing older on the feed's relays: the big ones keep days, so
+ // the history goes on on the archive relays, which keep years (the
+ // grid stopped at five clips, "all caught up")
+ if (!r.deep) { r.deep = true; pass--; continue; }
// an empty answer is the bottom only when it repeats: a relay that
// timed out, or a socket the phone froze, answers empty too
if (++r.empty >= 3) { r.end = Date.now(); break; }
@@ -11682,7 +12041,7 @@ export function messagesFeature(ctx) {
if (res) { videoThumbs.set(url, URL.createObjectURL(await res.blob())); found++; }
}
} catch {}
- if (found && mediaMode !== 'all') scheduleRepaint();
+ if (found && !listView()) scheduleRepaint();
}
function keepVideoThumb(v, url) {
if (videoThumbs.has(url)) return Promise.resolve(true);
@@ -11794,6 +12153,68 @@ export function messagesFeature(ctx) {
pumpFrames();
return task;
}
+ // ---- the pager's first-frame posters -------------------------------------
+ // The clip ahead holds its own first frame — full size, frame 0, drawn
+ // from the opening of the file (the bytes headStart put in the cache,
+ // read again by range) — so a flick to it glides onto the very picture
+ // the player will show first, and the player's frame replaces it with no
+ // visible change. (Adam, 2026-10-16: "could we actually have the next
+ // image or video scroll in while the previous scrolls out? Right now it
+ // goes black before the next one scrolls in.") A dozen are kept, the
+ // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the
+ // same decode when the tile has none yet.
+ const PAGER_POSTERS = 12, POSTER_W = 1100;
+ const pagerPosters = new Map(); // url -> object URL, oldest first
+ const posterGrabs = new Map(); // url -> promise
+ function pagerPoster(url) {
+ if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); }
+ if (posterGrabs.has(url)) return posterGrabs.get(url);
+ const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url));
+ posterGrabs.set(url, task);
+ return task;
+ }
+ async function grabPoster(url) {
+ const d = videoInfo.get(url) || {};
+ if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null;
+ // no more of the file than the head start fetched: the bytes come from the cache
+ const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1;
+ let r;
+ try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); }
+ catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket)
+ if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; }
+ const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' });
+ const src = URL.createObjectURL(blob);
+ const v = document.createElement('video');
+ v.muted = true; v.playsInline = true; v.preload = 'auto';
+ try {
+ const shown = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(v.readyState >= 2), 6000);
+ v.onloadeddata = () => { clearTimeout(timer); resolve(true); };
+ v.onerror = () => { clearTimeout(timer); resolve(false); };
+ v.src = src;
+ });
+ if (!shown || !v.videoWidth) return null;
+ const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth);
+ const cv = document.createElement('canvas');
+ cv.width = w; cv.height = hh;
+ cv.getContext('2d').drawImage(v, 0, 0, w, hh);
+ const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9));
+ if (!out) return null;
+ const p = URL.createObjectURL(out);
+ pagerPosters.set(url, p);
+ while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); }
+ // the tile's frame too, from the same decode, when it has none
+ if (!videoThumbs.has(url)) {
+ await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } });
+ await keepVideoThumb(v, url).catch(() => {});
+ }
+ return p;
+ } finally {
+ v.onloadeddata = v.onseeked = v.onerror = null;
+ try { v.removeAttribute('src'); v.load(); } catch {}
+ URL.revokeObjectURL(src);
+ }
+ }
function pumpFrames() {
while (frameGrabbing < 2 && frameQueue.length) {
const { url, resolve } = frameQueue.shift();
@@ -11833,11 +12254,42 @@ export function messagesFeature(ctx) {
// stand taller than they're wide. A clip of unknown shape is measured
// (the header probe, or a player when the index sits at the end of a
// file a server serves in ranges) and joins once it's known to qualify.
- function verticalClip(url) {
+ // ---- which clips fit the screen -----------------------------------------
+ // The Videos view and the pager show the clips the screen's way round: tall
+ // ones on a phone held upright, wide ones held sideways (or on a desktop
+ // window), a square either way — unless the reader asks for all shapes
+ // (remembered on this device). (Adam, 2026-10-16: "only show vertical
+ // videos by default on a phone that's in vertical orientation and only
+ // horizontal videos if the phone is in landscape but have some kind of
+ // control to toggle the ability to see all videos".)
+ const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round
+ const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } };
+ const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} };
+ const screenTall = () => window.innerHeight >= window.innerWidth;
+ const clipShape = (url) => {
const d = videoInfo.get(url) || {};
const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url);
const w = k && (k.w || k.width), hh = k && (k.h || k.height);
- if (w && hh) return hh > w;
+ return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null;
+ };
+ // true/false, or null while the clip's shape isn't known
+ const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); };
+ const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide');
+ // the toggle: in the pager's top bar only (over the grid it sat oddly
+ // apart from the chips); the grid follows the same setting
+ function clipsToggle(cls, after) {
+ return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => {
+ e.stopPropagation();
+ setClipsAll(!clipsAll());
+ for (const b of document.querySelectorAll('.mp-fit')) b.textContent = clipsLabel();
+ if (after) after();
+ if (mediaMode === 'videos') scheduleRepaint();
+ } }, clipsLabel());
+ }
+ function fitClip(url) {
+ const f = clipFits(url);
+ if (f !== null) return f;
+ const d = videoInfo.get(url) || {};
if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url);
return false;
}
@@ -11860,7 +12312,7 @@ export function messagesFeature(ctx) {
// a grid shows the picture itself — no warning to fold it behind
if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue;
if (kind === 'videos') learnVideoMeta(ev);
- const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip)
+ const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip)
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
for (const url of urls) {
const it = { ev, url };
@@ -12013,15 +12465,38 @@ export function messagesFeature(ctx) {
if (fsElement() === document.documentElement) exitFs();
}
// The words of the post, without the links to the media it carries.
- function mediaCaption(ev) {
+ function mediaCaption(ev, raw = false) {
let s = isVideoEvent(ev) ? videoEventText(ev) : String(ev.content || '');
s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m));
for (const u of [...noteMediaUrls(s), ...noteVideoUrls(s)]) s = s.split(u).join('');
- return plainExcerpt(s);
+ return raw ? s.replace(/\s+/g, ' ').trim() : plainExcerpt(s);
+ }
+ // The pager's caption: the post's words with their names, tags and links
+ // live (a plain excerpt left "@NostrValley" dead — Adam, 2026-10-16). A
+ // tap on a name, a tag or a note reference leaves the pager, and the full
+ // screen with it, before going where it goes; a link to the web opens in
+ // its own tab and the pager stays. A tap anywhere else on the caption
+ // leaves the pager and opens the post ("clicking anywhere on the caption
+ // that's not a link should take me out of fullscreen and take me to the
+ // post"), where the whole of a long caption reads.
+ function captionNode(ev, text) {
+ const el = h('div', { class: 'mp-caption' }, ...noteBody(text, 2, noteEmojiTags(ev)));
+ el.addEventListener('click', (e) => {
+ const a = e.target && e.target.closest ? e.target.closest('a') : null;
+ if (a && /^https?:/i.test(a.getAttribute('href') || '')) { e.stopPropagation(); return; }
+ leavePager();
+ if (a) return; // the link's own handler opens the name, the tag, the note
+ e.stopPropagation(); e.preventDefault();
+ openNoteThread(ev);
+ }, true);
+ return el;
}
function setMediaMode(m) {
mediaMode = m;
try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {}
+ // Notes ↔ Conversations: another feed state (the view's), entered like
+ // a switch of feed — its watch, its fresh counter — with no change of chip
+ if (listView() && feedDef(curFeedId)) selectFeed(curFeedId);
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -12031,7 +12506,7 @@ export function messagesFeature(ctx) {
onClick: () => setMediaMode(m),
}, label);
return h('div', { class: 'row media-modes', 'data-key': 'media-modes' },
- b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles')));
+ b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles')));
}
function mediaGrid(c) {
const items = mediaItems(c, mediaMode);
@@ -12275,9 +12750,21 @@ export function messagesFeature(ctx) {
// and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as
// they scroll"). A copy that fails falls back to the original.
const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url;
+ // A clip that will play by itself has no poster: every poster there is
+ // (the kept frame, a post's imeta picture, the server's) is 480px wide
+ // and taken at some moment of the clip, so it showed for a beat, soft
+ // and not quite the first frame, and the clip's own first frame
+ // replaced it — a flash on every flick (Adam, 2026-10-16: "if we're
+ // auto playing let's not show the thumbnail let's just start on the
+ // first frame of the video"). The slide is the pager's black until the
+ // player, whose opening is in the cache by then (headStart), has its
+ // first frame. (Giving the next clip's player its file ahead of time
+ // was tried: it pulled the whole file down.) On a connection to be
+ // careful with the poster stays: the clip may never load (a heavy one
+ // waits for a tap), and the still is what there is.
const media = video
? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none',
- poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) })
+ poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined })
: h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async',
onError: (e) => {
const m = e.currentTarget;
@@ -12288,7 +12775,7 @@ export function messagesFeature(ctx) {
m.closest('.mp-slide').classList.add('mp-gone');
m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null));
} });
- const caption = mediaCaption(ev);
+ const caption = mediaCaption(ev, true);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
media,
@@ -12296,7 +12783,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'mp-left' },
h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { leavePager(); openProfile(ev.pubkey); }) },
avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))),
- caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null),
+ caption ? captionNode(ev, caption) : null),
h('div', { class: 'mp-counts' },
// as in the feed: a tap picks how you feel about it (the emoji
// sheet, over the pager), a tap again takes it back
@@ -12383,7 +12870,13 @@ export function messagesFeature(ctx) {
// post), each a row of its pictures or clips — its quotes' too — side by
// side (left and right go within it). Its slides, in reading order:
const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []);
- const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []);
+ // the posts in the carousel: those on offer (a clip's post waits out of it
+ // until it is ready to play, and a clip the wrong way round for the screen
+ // is kept out — see pagerReveal, pagerFit), or every one built (all)
+ const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)';
+ const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []);
+ // the posts found: the ready ones and those still warming, not those kept out for their shape
+ const pagerFound = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group:not(.mp-off)')] : []);
// the slide a post's row rests on
const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; };
// bring a slide to the screen: its post, and within it, the slide
@@ -12397,13 +12890,17 @@ export function messagesFeature(ctx) {
// along (the next in this post), and the post after next
function pagerAhead(slide) {
const out = [];
- const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; };
+ // (a post kept out for its shape is passed over: nothing to ready there)
+ const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; };
const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1);
const down = g1 && rowSlide(g1);
if (down) out.push(down);
if (slide.nextElementSibling) out.push(slide.nextElementSibling);
const down2 = g2 && rowSlide(g2);
if (down2) out.push(down2);
+ const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; };
+ const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up);
+ if (back) out.push(back); // (the one before: ready again for the swipe back)
return out;
}
function pagerAttach(slide) {
@@ -12451,6 +12948,8 @@ export function messagesFeature(ctx) {
slides.forEach((s, j) => {
const k = ahead.indexOf(s);
if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s);
+ // the slides around the reader (and the one landed on) hold their clip's first frame
+ if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k);
if (j === i) return;
const v = s.querySelector('video.mp-media');
if (eager && k >= 0) {
@@ -12469,6 +12968,56 @@ export function messagesFeature(ctx) {
});
for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src'));
}
+ // A slide near the reader holds its clip's first frame as its poster, so
+ // a flick glides onto the very picture the player shows first and the
+ // player takes over with no visible change — and a slide left behind
+ // (its player let go) keeps it for the swipe back. Some slides came up
+ // black until the player had its frame: the frame drawn here needed the
+ // next clip's cached opening, a file led by its index and a host that
+ // allows the read, and only the next clip had one at all — not the one
+ // before, not a flight's landing (Adam, 2026-10-16: "i'm still seeing
+ // mostly black between some videos and then they suddenly appear when
+ // it starts playing"). The server's first frame (vmedia first.jpg: frame
+ // 0 at full size, any host, any layout, cached at the CDN) is the first
+ // choice, fetched and decoded aside and put on the slide when it is
+ // ready; the frame drawn here is the fallback for the next clip. A clip
+ // with neither gets no picture at all — and so stays out of the carousel
+ // (pagerReveal): the post's own still, put up meanwhile, was often not
+ // the clip's first frame, and the swap from one to the other jarred
+ // (Adam, 2026-10-17: "i don't want to show posters and then switch to
+ // the first frame … could we just always wait until we have a non-black
+ // frame before including the video in the pager?").
+ function slideFrame(s, k) {
+ const v = s.querySelector('video.mp-media');
+ if (!v || v._frame === 'first' || v._frameBusy) return;
+ const url = v.getAttribute('data-src');
+ const put = (p, kind) => {
+ if (!p || !pager || !s.isConnected || v._frame === 'first') return false;
+ v.setAttribute('poster', p); v._frame = kind;
+ pagerReveal();
+ return true;
+ };
+ const first = serverFirst(url);
+ if (first && !serverFirstFailed.has(url)) {
+ v._frameBusy = true;
+ const im = new Image();
+ im.decoding = 'async';
+ const ok = () => {
+ v._frameBusy = false;
+ if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame
+ put(first, 'first');
+ };
+ im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok());
+ im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); };
+ im.src = first;
+ return;
+ }
+ if (k === 0 && !v._grabbed) { // the next clip: its opening is in the cache, the frame comes from there
+ v._grabbed = true;
+ v._frameBusy = true;
+ pagerPoster(playSrc(url)).then((p) => { v._frameBusy = false; put(p, 'first'); });
+ }
+ }
// The opening of a clip about to be reached, fetched into the browser's
// HTTP cache with a range request: the player's own request for the file
// is answered from there as far as it goes, and streams on from the host.
@@ -12501,14 +13050,19 @@ export function messagesFeature(ctx) {
if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size;
return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity));
}
- const headStarted = new Set();
+ const headStarted = new Map(); // url -> its promise
+ const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read)
function headStart(url, share = 1) {
- if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return;
- headStarted.add(url);
- if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value);
+ if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve();
+ if (headStarted.has(url)) return headStarted.get(url);
+ if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's
+ const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); });
+ headStarted.set(url, task);
+ return task;
+ }
const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share));
const t0 = performance.now();
- fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
+ const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
const reader = r.ok && r.body ? r.body.getReader() : null;
if (!reader) return;
let got = 0;
@@ -12520,7 +13074,20 @@ export function messagesFeature(ctx) {
}
const secs = (performance.now() - t0) / 1000;
if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; }
- }).catch(() => {});
+ }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); });
+ headStarted.set(url, task);
+ if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); }
+ return task;
+ }
+ async function hlsHead(url) {
+ const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; };
+ const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; };
+ const master = await get(url), text = await master.text();
+ // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself
+ let list = text, at = master.url || url;
+ if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); }
+ const seg = uri(list, at);
+ if (seg) await (await get(seg)).arrayBuffer();
}
function playSlide(slide) {
const v = slide.querySelector('video.mp-media');
@@ -12535,11 +13102,11 @@ export function messagesFeature(ctx) {
// first frame itself (only the start of the file, when it leads with
// its index or is served in ranges) — again on every return, since
// a slide left behind lets go of its file
- slidePoster(slide);
+ slidePoster(slide, true);
if (videoThumbs.has(url)) pagerRelease(slide);
else grabFrame(url).then((kept) => {
if (!pager || !slide.isConnected) return;
- if (kept) { slidePoster(slide); return; }
+ if (kept) { slidePoster(slide, true); return; }
// no frame to keep (the host's bytes can't be read, or the index
// trails): the player shows its own first frame under the cover
const d = videoInfo.get(url) || {};
@@ -12561,8 +13128,10 @@ export function messagesFeature(ctx) {
});
}
// the kept frame as the poster, looked up as the slide comes near (it may
- // have been kept after the slide was built)
- function slidePoster(slide) {
+ // have been kept after the slide was built) — only where a poster is
+ // wanted: a careful connection, or a heavy clip under its cover (force)
+ function slidePoster(slide, force = false) {
+ if (!force && !frugalData()) return;
const v = slide.querySelector('video.mp-media');
const thumb = v && videoThumbs.get(v.getAttribute('data-src'));
if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb);
@@ -12606,7 +13175,7 @@ export function messagesFeature(ctx) {
// still after a fast flick; snapping stays off until it lands.
// A longer glide (a flight past several posts) says how long it takes;
// onDone hears the landing, or the interruption (true) that caught it.
- function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) {
+ function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null, ease = 3 } = {}) {
pagerHold(box); // a glide under way is caught where it is
const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from;
box.style.scrollSnapType = 'none';
@@ -12619,7 +13188,7 @@ export function messagesFeature(ctx) {
let t0 = 0;
const step = (now) => {
if (!t0) t0 = now - 8; // (a frame's worth in already: no frame stands still)
- const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, 3);
+ const k = Math.max(0, Math.min(1, (now - t0) / ms)), e = 1 - Math.pow(1 - k, ease);
if (y) box.scrollTop = from + dist * e; else box.scrollLeft = from + dist * e;
if (k < 1) box._glide = requestAnimationFrame(step); else done();
};
@@ -12648,12 +13217,9 @@ export function messagesFeature(ctx) {
if (v) {
const groups = pagerGroups();
const from = here && here.closest('.mp-group');
- const index = groups.indexOf(from) + v;
- const to = groups[(index + groups.length) % groups.length];
- if (to) {
- if (index < 0 || index >= groups.length) pagerWrap(to);
- else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
- }
+ // the ends are ends: no going round to the other (Adam, 2026-10-18)
+ const to = groups[groups.indexOf(from) + v];
+ if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
return !!to;
}
if (hz) {
@@ -12672,6 +13238,15 @@ export function messagesFeature(ctx) {
// it is passing and steps back from there; a touch or a key catches it
// too (pagerHold → onDone). A notch from rest is the single step it was.
let fly = null; // { to: the post flown to, n: notches in this run, at, dir }
+ // A pager flies only once ten posts are ready (in the carousel: clips
+ // ready to play, pictures decoded): before that a fling outran the
+ // warming, landing on whatever was ready and fighting the posts joining
+ // (Adam, 2026-10-17: "let's not allow flying/coasting until we have at
+ // least 10 videos fully warmed up"; the picture pager the same: "image
+ // pager should work similar"). Until then a flick or a spin of the wheel
+ // is one post at a time.
+ const PAGER_FLY_READY = 10;
+ const pagerCanFly = () => !pager || (pager.kind === 'videos' && frugalData()) || pagerGroups().length >= PAGER_FLY_READY;
function pagerFly(dir, now) {
if (!pager) return;
const stops = pagerGroups();
@@ -12686,10 +13261,10 @@ export function messagesFeature(ctx) {
const here = pagerHere();
from = here && here.closest('.mp-group');
}
- const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2);
- const i = (next % stops.length + stops.length) % stops.length;
- if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
+ // a flight stops at the end it reaches
+ const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2)));
const to = stops[i];
+ if (to === from && !live) return;
fly = { to, n, at: now, dir };
if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
pagerFlyTo(to);
@@ -12701,12 +13276,30 @@ export function messagesFeature(ctx) {
const dest = pagerAt(pager.scroller, to, true);
const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
pager.flying = true;
+ pager.flyDir = Math.sign(dest - pager.scroller.scrollTop);
+ const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way
+ if (land) slideFrame(land, -1);
+ // A flight eases out on a square, not a cube: on a cube a long one
+ // spent its last 400 ms creeping under 3 px a frame (Adam's phone, a
+ // nine-post flight), and the picture coming to rest stepped a pixel
+ // at a time — the stutter "towards the end of the fly as it's
+ // snapping" (Adam, 2026-10-17); and it set off at three times its
+ // average speed, faster than the finger that flung it.
pagerGlide(pager.scroller, 'y', dest, vel, {
+ ease: screens > 1.05 ? 2 : 3,
ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0,
- onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); },
+ onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); pagerPosition(); },
});
}
const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
+ // A finger down on a flight stops it there and then, settling on the post
+ // most on screen (Adam, 2026-10-17: "Tapping … should instantly stop the
+ // flying … and snap to the video that's most on screen").
+ function pagerCatch() {
+ pagerHold(pager.scroller); // the flight's onDone: no longer flying, arrived at the post most on screen
+ const here = pagerHere(), g = here && here.closest('.mp-group');
+ if (g) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, g, true));
+ }
// the sideways arrows show only where the post has a picture that way
function pagerArrows(slide) {
if (!pager || !slide) return;
@@ -12714,35 +13307,44 @@ export function messagesFeature(ctx) {
pager.el.classList.toggle('has-right', !!slide.nextElementSibling);
}
- // Crossing the boundary jumps directly to the other end, without flying
- // backwards through every loaded post or waiting for a network request.
- function pagerWrap(group) {
- if (!pager || !group) return;
- pager.scroller._onDone = null;
- pagerHold(pager.scroller);
- pager.scroller.style.scrollSnapType = '';
- pager.flying = false;
- const slide = rowSlide(group);
- if (slide) { pagerShow(slide); pagerArrive(slide); }
- }
+ // Counted: the posts in the carousel — the ones that can be reached. (All
+ // the posts found, the waiting ones too, was tried and undone: a count
+ // that can't be scrolled to confused — Adam, 2026-10-17.)
function pagerPosition() {
if (!pager) return;
const groups = pagerGroups();
const slide = pager.cur || pager.anchor;
- const index = groups.indexOf(slide && slide.closest('.mp-group'));
- pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length;
+ let index = groups.indexOf(slide && slide.closest('.mp-group'));
+ // in a flight, the post passing under the reader: the count runs with
+ // the slides rather than jumping on landing (Adam, 2026-10-17: "the
+ // counter should animate as we fly")
+ if (pager.flying && groups.length) {
+ const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2;
+ let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen
+ while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; }
+ index = lo;
+ }
+ const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position');
+ if (el.textContent !== text) el.textContent = text;
+ // a video pager shows no count until it can fly: ten clips ready (Adam,
+ // 2026-10-17: "let's also not show the position/total counter until we have 10")
+ // (one with fewer posts than that, every one of them ready, counts them)
+ pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)'));
}
function buildPager(want) {
const video = want.kind === 'videos';
const scroller = h('div', { class: 'mp-scroll' });
+ scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true });
const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' },
scroller,
h('div', { class: 'mp-position', role: 'status' }, '0 / 0'),
h('div', { class: 'mp-top' },
h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'),
- video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
- h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null),
+ h('div', { class: 'mp-top-r' },
+ clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }),
+ video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
+ h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)),
// a mouse's way through (shown where there is a pointer to hover with,
// style.css): up and down the posts at the right edge, left and right
// along a post's pictures when it has more than one (has-left/right)
@@ -12813,10 +13415,16 @@ export function messagesFeature(ctx) {
let touch = null, pendingTap = 0, tapDoneAt = 0;
el.addEventListener('touchstart', (e) => {
const t = e.touches[0];
+ // caught in flight: stopped where it is (a two-finger touch, a touch
+ // on the seek bar too); a swipe back from there is the post before it
+ // however hard (Adam, 2026-10-17: "reverse swiping should go to the
+ // previous video"), one on the way it was flying takes it on
+ const caught = !!pager && pager.flying && !!pager.scroller._glide ? pager.flyDir || 0 : 0;
+ if (caught) pagerCatch();
const img = e.target.closest && e.target.closest('img.mp-media');
// not a touch on the seek bar, in an opened caption, or on a picture zoomed in (each its own)
touch = e.touches.length === 1 && pager && !e.target.closest('.mp-seek, .mp-caption.open') && !(img && img.style.touchAction === 'none')
- ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target } : null;
+ ? { x: t.clientX, y: t.clientY, at: Date.now(), slide: pager.cur, target: e.target, caught } : null;
}, { passive: true });
el.addEventListener('touchmove', (e) => {
const d = touch;
@@ -12836,24 +13444,33 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? dy : dx;
d.far = Math.max(d.far || 0, Math.hypot(dx, dy));
if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta;
- d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp)
+ // Timed when the finger moved (the event's timeStamp), not when the
+ // move was handled: right after a refresh, with clips warming, moves
+ // queue behind other work and arrive in bursts — timed on handling, a
+ // burst after a stall read as a fling (a short flick flew) and a
+ // stall at the end as a crawl (a long flick took one step) (Adam,
+ // 2026-10-17: "sometimes a quick long flick only proceeds to the next
+ // video … sometimes … a short flick does fly"). The handling time is
+ // kept for moves that share a timeStamp (synthetic ones can).
+ d.track.push({ t: e.timeStamp, h: performance.now(), v: delta });
while (d.track.length > 2 && d.track[d.track.length - 1].t - d.track[1].t >= TRACK_MS) d.track.shift();
}, { passive: false });
// let go of a drag: on to the next (or the one before), or back
function settle(d, step, vel = 0) {
const box = d.box, y = d.axis === 'y';
- const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y));
+ const stops = [...box.children].filter((c) => c.matches(y ? PAGER_ON : '.mp-slide')).map((c) => pagerAt(box, c, y));
if (!stops.length) return;
// the stop it set off from, then one along (or back) — or, flung, several
const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0);
- const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ const posts = y && step && Math.abs(vel) >= FLY_V && pagerCanFly() && !(d.caught && step !== d.caught) ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1;
+ // past either end, it stops at the end: going round to the other end
+ // was tried and undone (Adam, 2026-10-18: "let's not make the media
+ // pager circle around to the start anymore")
const next = from + step * posts;
if (y && posts > 1) {
const i = Math.min(stops.length - 1, Math.max(0, next));
if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; }
}
- // past either end: round to the other (a fling at the end too — one post of wrap, not its whole count)
- if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(from + step + stops.length) % stops.length]); return; }
pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel);
}
el.addEventListener('touchend', (e) => {
@@ -12865,12 +13482,15 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? t.clientY - d.y : t.clientX - d.x;
const size = d.axis === 'y' ? d.box.clientHeight : d.box.clientWidth;
const a = d.track[0], b = d.track[d.track.length - 1];
- const vel = a && b && b.t > a.t ? (b.v - a.v) / (b.t - a.t) : 0;
+ const dt = a && b ? (b.t - a.t >= 8 ? b.t - a.t : b.h - a.h) : 0;
+ const vel = dt > 0 ? (b.v - a.v) / dt : 0;
const tap = d.far < TAP_SLOP && Date.now() - d.at < TAP_MS;
const go = !tap && (Math.abs(delta) > size * (d.axis === 'y' ? SWIPE_Y : SWIPE_X) || (Math.abs(delta) >= FLICK_MIN && Math.abs(vel) > FLICK && Math.sign(vel) === Math.sign(delta)));
settle(d, go ? (delta < 0 ? 1 : -1) : 0, go ? vel : 0);
if (!tap) return;
}
+ // the tap that caught a flight only stopped it (the buttons still work)
+ if (d.caught && !(d.target.closest && d.target.closest('button, a'))) { tapDoneAt = Date.now(); return; }
if (Date.now() - d.at > 500) return; // a press, not a tap
clearTimeout(pendingTap);
pendingTap = setTimeout(() => {
@@ -12929,9 +13549,13 @@ export function messagesFeature(ctx) {
// pictures are cheap — a post's all at once, the ones along its row
// too (out of sight sideways, they'd load only on arrival); clips
// are pagerNeighbours' call
- if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x);
+ if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) {
+ pagerAttach(x);
+ const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank
+ if (im && im.decode) im.decode().catch(() => {});
+ }
}
- }, { root: scroller, rootMargin: '150% 0px' });
+ }, { root: scroller, rootMargin: '250% 0px' });
// a slide has come to rest on screen (or a flight has landed on it)
pager.arrive = (slide) => {
if (!pager) return;
@@ -12954,6 +13578,7 @@ export function messagesFeature(ctx) {
playSlide(slide);
pagerCounts(slide);
pagerLoadAhead();
+ pagerWarmPics();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -12972,10 +13597,25 @@ export function messagesFeature(ctx) {
pager.anchor = first; // the slide meant to be on screen, from the first moment
pager.screenH = scroller.clientHeight;
pagerLoadAhead();
- // the clip on screen's seek bar, every frame (timeupdate comes a few times a second)
- if (video) {
+ // the clip on screen's seek bar, every frame (timeupdate comes a few
+ // times a second). While the pager is in motion — under a finger, in a
+ // glide or a flight, snapping back — no seek bar shows at all: the
+ // clip's slide is off its mark, and the bar rode along with it, a
+ // white line sliding up the screen (Adam, 2026-10-16: "i don't want to
+ // see the seek bar while i'm transitioning to the next video"). It
+ // fades back in once the clip is square on the screen. (The motion flag
+ // is a picture pager's too: its counter shows while it moves.)
+ {
const p = pager;
- const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); };
+ const frame = () => {
+ if (pager !== p) return;
+ const bar = p.cur?.querySelector('.mp-seek');
+ if (bar) bar._paint();
+ const r = p.cur && p.cur.getBoundingClientRect();
+ p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2));
+ if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); }
+ p.raf = requestAnimationFrame(frame);
+ };
p.raf = requestAnimationFrame(frame);
}
// turned on its side (or back), the slides change height: stay on this one
@@ -12991,6 +13631,8 @@ export function messagesFeature(ctx) {
if (!pager) return;
pager.screenH = pager.scroller.clientHeight;
if (pager.anchor) pagerShow(pager.anchor);
+ for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel();
+ pagerFit();
};
window.addEventListener('resize', pager.onResize);
// new posts land in the feed while the pager is open; counts move
@@ -13020,7 +13662,7 @@ export function messagesFeature(ctx) {
async function pagerLoadAhead() {
const p = pager;
if (!p) return;
- const groups = pagerGroups();
+ const groups = pagerGroups(true);
const slide = p.cur || p.anchor;
p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group')));
if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return;
@@ -13029,7 +13671,8 @@ export function messagesFeature(ctx) {
try {
// Bound each run; the timer resumes if sparse media needs more pages.
for (let pages = 0; pages < 8 && pager === p; pages++) {
- if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
+ // (the posts found, waiting ones too: ready ones come only as fast as they warm)
+ if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
const src = pagerLoadSource(p);
if (src.done()) break;
const before = src.progress();
@@ -13061,16 +13704,114 @@ export function messagesFeature(ctx) {
let g = pager.groups.get(gk);
if (!g) {
g = pagerGroup();
+ // out of the carousel until it's ready: a clip to play, a picture decoded
+ if (pager.kind === 'images' || !frugalData()) g.classList.add('mp-wait');
pager.groups.set(gk, g);
pager.scroller.append(g);
}
+ if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state
g._row.append(s);
pagerDots(g);
pager.near.observe(s);
pager.io.observe(s);
}
+ pagerFit();
pagerPosition();
+ pagerWarmPics();
}
+ // A picture post is ready once its picture is decoded: only then does it
+ // join the carousel, so a flight passes over pictures already in hand.
+ // They were fetched and decoded as the flight came near them, and the
+ // flight stuttered — dropped frames with no script running, the decoding
+ // (Adam, 2026-10-17: "some stutter/choppiness when flying images"; seen
+ // on his phone: 66 ms frames mid-flight as pictures arrived). A few at a
+ // time are decoded ahead of the reader (and a few behind, for the way
+ // back), out of sight; its size learned, a picture the wrong way round
+ // for the screen stays out like a clip (pagerFit). One that can't be had
+ // at all — copy and original both gone — stays out.
+ const PIC_WARM_AT_ONCE = 4, PIC_READY_AHEAD = 16, PIC_WARM_BEHIND = 3;
+ function pagerWarmPics() {
+ if (!pager || pager.kind !== 'images') return;
+ const all = pagerFound();
+ const cur = pager.cur || pager.anchor, ci = Math.max(0, all.indexOf(cur && cur.closest('.mp-group')));
+ let busy = all.filter((g) => g._warming).length;
+ const want = (g) => g.classList.contains('mp-wait') && !g._warming && !g._failed;
+ for (let j = ci + 1, ready = 0; j < all.length && busy < PIC_WARM_AT_ONCE && ready < PIC_READY_AHEAD; j++) {
+ if (!all[j].classList.contains('mp-wait')) { ready++; continue; }
+ if (want(all[j])) { warmPic(all[j]); busy++; }
+ }
+ for (let j = ci - 1; j >= Math.max(0, ci - PIC_WARM_BEHIND) && busy < PIC_WARM_AT_ONCE; j--) if (want(all[j])) { warmPic(all[j]); busy++; }
+ }
+ function warmPic(g) {
+ const p = pager;
+ g._warming = true;
+ for (const x of g._row.children) pagerAttach(x);
+ const m = g._row.firstElementChild && g._row.firstElementChild.querySelector('img.mp-media');
+ // the copy failing falls back to the original (its onError): decoded again
+ const decode = (n) => {
+ const src = m.getAttribute('src');
+ return m.decode().then(() => true, () => new Promise((r) => setTimeout(r, 60)).then(() =>
+ n < 2 && m.getAttribute('src') !== src && !m.closest('.mp-slide').classList.contains('mp-gone') ? decode(n + 1) : false));
+ };
+ const timeout = new Promise((r) => setTimeout(() => r(false), 20000));
+ Promise.race([m ? decode(0) : Promise.resolve(true), timeout]).then((ok) => {
+ g._warming = false;
+ if (pager !== p || !g.isConnected) return;
+ if (!ok) { g._failed = true; pagerWarmPics(); return; }
+ const url = m && m.getAttribute('data-full');
+ if (url && m.naturalWidth && !mediaReady.has(url)) mediaReady.set(url, { width: m.naturalWidth, height: m.naturalHeight }); // its shape
+ pagerFit();
+ pagerToggleGroup(g, 'mp-wait', false);
+ pagerWarmPics();
+ });
+ }
+ // A post's clip is ready to play when its first frame is on the slide and
+ // its opening is in the cache (the head start done): only then does the
+ // post join the carousel, so the reader never lands on a black slide
+ // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather
+ // just not have a video in carousel yet if it's not ready to play"). A
+ // post joining above the one on screen would push it down: the scroll
+ // moves by the post's height in the same frame, snapping off meanwhile,
+ // and not while the pager is in motion (then, once it has settled).
+ function pagerReveal() {
+ if (!pager || pager.kind !== 'videos') return;
+ const eager = !frugalData();
+ for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) {
+ const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media');
+ if (!v) continue;
+ const url = v.getAttribute('data-src');
+ // the frame is asked of a clip that can have one (the server's: an https
+ // clip); a local http clip (a dev setup) waits only for its opening
+ if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue;
+ pagerToggleGroup(g, 'mp-wait', false);
+ }
+ }
+ // the posts whose clips are the wrong way round for the screen stay out
+ // (not the one opened on; a shape not yet known is let in)
+ function pagerFit() {
+ if (!pager) return;
+ const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ for (const g of pagerGroups(true)) {
+ const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; });
+ pagerToggleGroup(g, 'mp-off', !fits);
+ }
+ }
+ function pagerToggleGroup(g, cls, on) {
+ if (!pager || g.classList.contains(cls) === on) return;
+ const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING);
+ if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop)
+ const box = pager.scroller;
+ const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count)
+ const h0 = shown ? g.offsetHeight : 0;
+ box.style.scrollSnapType = 'none';
+ g.classList.toggle(cls, on);
+ if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0;
+ requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; });
+ pagerPosition();
+ if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now
+ }
+ const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving'));
// A post in the pager: its row of slides, side by side, and — with more
// than one — dots saying how many and which.
function pagerGroup() {
@@ -13116,8 +13857,8 @@ export function messagesFeature(ctx) {
const visitor = isVisitor();
queueMicrotask(() => {
syncMediaPager();
- if (mediaMode === 'all') watchSeen();
- if (mediaMode !== 'all') watchMediaTiles();
+ if (listView()) watchSeen();
+ else watchMediaTiles();
});
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
@@ -13144,7 +13885,7 @@ export function messagesFeature(ctx) {
const visNotes = c.notes, visShown = c.shown;
c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE));
const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id)
- ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev)));
+ ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id) : feedRow(c, ev)));
// Posts that went in above you while you were reading. A floating pill
// that says how many are up there; the tap takes you up to them.
// Keyed, so the morph keeps this very node while the count changes —
@@ -13214,7 +13955,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'small muted' }, t('feedNoFollows')),
h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')),
// your own posts, under the invitation
- noFollows && visible.length && mediaMode === 'all' ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null)
+ noFollows && visible.length && listView() ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null)
: h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('feedNoQuery')),
h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit')))
@@ -13223,16 +13964,16 @@ export function messagesFeature(ctx) {
: !visible.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
: mediaMode === 'articles' ? articleList(c)
- : mediaMode !== 'all' ? mediaGrid(c)
- : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows),
+ : !listView() ? mediaGrid(c)
+ : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined, 'data-shown': c.shown, 'data-held': c.notes.length }, ...rows),
// The foot of the feed says what is happening down there: older
// posts on their way, or nothing older left to fetch. (Older pages
// prepare offscreen, so the line is mostly seen by a reader who
// outruns them.)
- c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : mediaMode !== 'all' && mediaCrawling)
+ c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling)
? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore')))
- : mediaMode !== 'all' ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null)
+ : !listView() ? (mediaMode === 'articles' && mediaIndex(c.id).art.end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null)
: c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length
? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd'))
: null,
@@ -13293,7 +14034,7 @@ export function messagesFeature(ctx) {
save(s);
feedsChanged();
if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id);
- feedStates.delete(id); // its query changed: rebuild from scratch
+ dropFeedState(id); // its query changed: rebuild from scratch
ui.feedEdit = null;
switchFeed(id);
// the list side: a feed of people goes out as a follow set (or, unticked,
@@ -13334,7 +14075,7 @@ export function messagesFeature(ctx) {
save(s);
feedsChanged();
if (def && def.d) deleteFollowSet(def).catch(() => {}); // after ours: it re-reads the state
- feedStates.delete(id);
+ dropFeedState(id);
try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {}
ui.feedEdit = null;
if (curFeedId === id) switchFeed(FOLLOWING); else render();
@@ -14884,6 +15625,28 @@ export function messagesFeature(ctx) {
let notif = null; // { status, items }
const NOTIF_V = 2;
let notifAt = 0, notifUnsub = null;
+ // Every notification event seen, id and time, for reconciliation (NIP-77)
+ // with the relays that speak it: a refresh then fetches only what's new,
+ // not the newest hundred and twenty again — a hundred kilobytes a visit.
+ // All of them, not the rows kept: a zap receipt folded into its twin, or
+ // a hidden author's reply, would otherwise come back on every refresh.
+ const NOTIF_SEEN_MAX = 300; // the relay reconciles its newest 120
+ let notifHeld = null; // id -> created_at, oldest first
+ function notifHeldItems() {
+ if (!notifHeld) {
+ notifHeld = new Map();
+ try { const s = st(); if (s.notifHeldPk === identityPk()) for (const [id, ts] of s.notifHeld || []) notifHeld.set(id, ts | 0); } catch {}
+ for (const x of notifNow().items) if (x && x.id && x.ts && !notifHeld.has(x.id)) notifHeld.set(x.id, x.ts);
+ }
+ return [...notifHeld].map(([id, ts]) => [ts, id]);
+ }
+ function noteNotifHeld(evs) {
+ notifHeldItems();
+ let grew = false;
+ for (const ev of evs || []) if (ev && ev.id && !notifHeld.has(ev.id)) { notifHeld.set(ev.id, ev.created_at | 0); grew = true; }
+ while (notifHeld.size > NOTIF_SEEN_MAX) notifHeld.delete(notifHeld.keys().next().value);
+ return grew;
+ }
const notifNoteAsking = new Set();
const notifSeen = () => st().notifSeen || 0;
// Notifications are about the IDENTITY, not the wallet: the stored list
@@ -14943,6 +15706,7 @@ export function messagesFeature(ctx) {
for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id));
const s2 = st();
s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V;
+ if (notifHeld) { s2.notifHeld = [...notifHeld]; s2.notifHeldPk = identityPk(); }
s2.notifNoteCache = notes; s2.notifNotesPk = identityPk();
save(s2);
}
@@ -15055,9 +15819,11 @@ export function messagesFeature(ctx) {
notifAt = Date.now();
const g = session;
try {
- const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000);
+ const evs = await syncOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, notifHeldItems(), { maxWait: 5000 });
if (g !== session) return; // asked for the previous account
+ const grew = noteNotifHeld(evs);
if (mergeNotifs(evs)) scheduleRepaint();
+ if (grew) persistNotifs();
} catch {} finally {
if (g === session) {
if (notif) notif.status = 'ready';
@@ -15609,7 +16375,7 @@ export function messagesFeature(ctx) {
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key
- stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear();
+ stopNotifWatch(); notif = null; notifHeld = null; notifAt = 0; notifNotes.clear();
session++;
zapWho.clear(); whoOpenIds.clear();
clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null;
diff --git a/src/feed-cache.js b/src/feed-cache.js
index 2c0b153..a4f3ac9 100644
--- a/src/feed-cache.js
+++ b/src/feed-cache.js
@@ -6,7 +6,7 @@ export const FEED_SEED_POSTS = 5;
const FULL_FEEDS = 4, MAX_FEEDS = 12, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000;
export function createFeedCache(storage, prefix, now = Date.now) {
const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id;
- const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string'
+ const valid = (e) => (e?.kind === 1 || e?.kind === 1068 || e?.kind === 1111) && typeof e.id === 'string' && typeof e.pubkey === 'string'
&& typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags);
function compact(notes, max = FEED_CACHE_POSTS) {
const kept = []; let size = 0;
diff --git a/src/i18n.js b/src/i18n.js
index cdbcfeb..ef56389 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -270,6 +270,10 @@ const STR = {
close: 'Close',
playVideo: 'Play',
videoUnmute: 'Unmute',
+ clipsTall: 'Tall only',
+ clipsWide: 'Wide only',
+ clipsAll: 'All shapes',
+ clipsToggleTitle: 'Which clips to show: the ones that fit the screen, or all',
videoTapToPlay: "Tap to play",
pagerFetchingVideo: "Fetching the next video…",
pagerFetchingPictures: "Fetching more pictures…",
@@ -303,6 +307,7 @@ const STR = {
currencyLabel: 'Show amounts in',
feedTitle: 'Feed',
feedFollowingName: 'Following',
+ noteReplyingTo: 'Replying to',
feedNew: 'New feed',
feedNewName: 'New feed',
feedEdit: 'Edit feed',
@@ -339,7 +344,8 @@ const STR = {
askSignInCreate: 'Create a new account',
askNotNow: 'Not now',
feedLangLabel: 'Language',
- mediaAll: "All",
+ mediaAll: "Notes",
+ mediaConversations: "Conversations",
mediaImages: "Images",
mediaVideos: "Videos",
mediaArticles: "Articles",
diff --git a/src/media-upload.js b/src/media-upload.js
index c06771b..e45d2fe 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -115,3 +115,49 @@ export async function uploadPublicMedia(file, signEvent, {
if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
throw new MediaUploadError('failed', details);
}
+
+// An HLS stream (streamVideo) onto one host: the media files first, then
+// each playlist with its files' addresses written in, then the master
+// playlist naming those — its address is the post's. One host for the lot,
+// and one that serves a playlist as one (ditto and nostr.download do;
+// primal's says text/plain, nostr.build won't take it); a host that refuses
+// any piece is passed over for the next. `files` maps each name the
+// playlists use to its File.
+export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net'];
+export function rewritePlaylist(text, urls) {
+ const to = (p) => urls.get(p) || p;
+ return text.split('\n').map((line) => {
+ const l = line.trim();
+ if (l && !l.startsWith('#')) return to(l);
+ return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`);
+ }).join('\n');
+}
+export async function uploadStream({ files, master }, signEvent, { servers = STREAM_SERVERS, fetcher, onProgress = null } = {}) {
+ const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p));
+ const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master);
+ const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1;
+ let lastError = null;
+ for (const server of servers) {
+ let done = 0;
+ const put = async (file) => {
+ const url = await uploadPublicMedia(file, signEvent, {
+ servers: [server], ...(fetcher ? { fetcher } : {}),
+ onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))),
+ });
+ done += file.size;
+ if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in)
+ return url;
+ };
+ try {
+ const urls = new Map();
+ for (const p of media) urls.set(p, await put(files.get(p)));
+ for (const p of lists) {
+ const text = rewritePlaylist(await files.get(p).text(), urls);
+ urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' })));
+ }
+ const text = rewritePlaylist(await files.get(master).text(), urls);
+ return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' }));
+ } catch (error) { lastError = error; }
+ }
+ throw lastError || new MediaUploadError('failed');
+}
diff --git a/src/negentropy.js b/src/negentropy.js
new file mode 100644
index 0000000..83f65ae
--- /dev/null
+++ b/src/negentropy.js
@@ -0,0 +1,197 @@
+// Negentropy (NIP-77): set reconciliation with a relay. The client holds a
+// set of (created_at, id) pairs — the events it already has for a filter —
+// and the relay holds its own. Instead of the relay re-sending every event
+// the filter matches (a DM catch-up re-served 200 gift wraps, half a
+// megabyte, on every subscribe; a notification refresh a hundred kilobytes),
+// the two sides exchange fingerprints of ranges of their sets, split the
+// ranges that differ, and end with the ids each side lacks — a few hundred
+// bytes, usually in one round. The events themselves are then fetched by id.
+//
+// Protocol version 1 (0x61), as strfry speaks it. A port of Doug Hoyte's
+// reference client (github.com/hoytech/negentropy, MIT, © 2023), trimmed to
+// the initiator's side plus what a test needs to play the relay.
+// Hashing is WebCrypto SHA-256, so everything here is async.
+
+const PROTOCOL_VERSION = 0x61;
+const ID_SIZE = 32;
+const FINGERPRINT_SIZE = 16;
+const MODE = { skip: 0, fingerprint: 1, idList: 2 };
+const MAX_TS = Number.MAX_SAFE_INTEGER; // "infinity": the bound past every item
+
+// ---- bytes ----------------------------------------------------------------
+const hexOf = (a) => { let s = ''; for (let i = 0; i < a.length; i++) s += (a[i] < 16 ? '0' : '') + a[i].toString(16); return s; };
+const bytesOf = (h) => { if (h instanceof Uint8Array) return h; if (h.length % 2) throw new Error('odd hex'); const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a; };
+const cmpBytes = (a, b) => { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return a.length === b.length ? 0 : a.length < b.length ? -1 : 1; };
+const cmpItem = (a, b) => (a.ts === b.ts ? cmpBytes(a.id, b.id) : a.ts < b.ts ? -1 : 1);
+
+class Out {
+ constructor() { this.parts = []; this.length = 0; }
+ push(a) { if (Array.isArray(a)) a = Uint8Array.from(a); this.parts.push(a); this.length += a.length; }
+ bytes() { const o = new Uint8Array(this.length); let p = 0; for (const a of this.parts) { o.set(a, p); p += a.length; } return o; }
+}
+class In {
+ constructor(a) { this.a = a; this.p = 0; }
+ get left() { return this.a.length - this.p; }
+ byte() { if (this.p >= this.a.length) throw new Error('negentropy: message ends early'); return this.a[this.p++]; }
+ take(n) { if (this.p + n > this.a.length) throw new Error('negentropy: message ends early'); const s = this.a.subarray(this.p, this.p + n); this.p += n; return s; }
+}
+// varints, big-endian 7-bit groups with a continuation bit (the protocol's)
+const putVar = (o, n) => { if (n === 0) return o.push([0]); const g = []; while (n > 0) { g.push(n % 128); n = Math.floor(n / 128); } g.reverse(); for (let i = 0; i < g.length - 1; i++) g[i] |= 128; o.push(g); };
+const getVar = (i) => { let n = 0; for (;;) { const b = i.byte(); n = n * 128 + (b & 127); if (!(b & 128)) return n; } };
+
+// ---- the fingerprint of a range: the ids summed mod 2^256, hashed with the count
+const sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
+function addInto(acc, id) {
+ let carry = 0;
+ for (let i = 0; i < ID_SIZE; i++) { const s = acc[i] + id[i] + carry; acc[i] = s & 255; carry = s >> 8; }
+}
+async function fingerprint(items, begin, end) {
+ const acc = new Uint8Array(ID_SIZE);
+ for (let i = begin; i < end; i++) addInto(acc, items[i].id);
+ const o = new Out(); o.push(acc); putVar(o, end - begin);
+ return (await sha256(o.bytes())).subarray(0, FINGERPRINT_SIZE);
+}
+
+// ---- the set ----------------------------------------------------------------
+// `items`: [[created_at, id hex], ...] in any order; duplicates are dropped.
+export class NegSet {
+ constructor(items = []) {
+ const seen = new Set();
+ this.items = [];
+ for (const [ts, id] of items) {
+ if (typeof id !== 'string' || id.length !== 64 || seen.has(id)) continue;
+ seen.add(id);
+ this.items.push({ ts: Math.max(0, Math.floor(ts || 0)), id: bytesOf(id) });
+ }
+ this.items.sort(cmpItem);
+ }
+ get size() { return this.items.length; }
+ // the first index in [begin, end) at or past the bound
+ lowerBound(begin, end, bound) {
+ let lo = begin, hi = end;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (cmpItem(this.items[m], bound) < 0) lo = m + 1; else hi = m; }
+ return lo;
+ }
+}
+
+// ---- one side of a reconciliation ------------------------------------------
+export class Negentropy {
+ constructor(set, { frameSizeLimit = 0 } = {}) {
+ if (frameSizeLimit && frameSizeLimit < 4096) throw new Error('negentropy: frame size limit too small');
+ this.set = set;
+ this.frameSizeLimit = frameSizeLimit;
+ this.initiator = false;
+ this.lastTsIn = 0; this.lastTsOut = 0;
+ }
+ // the client's opening message (hex)
+ async initiate() {
+ this.initiator = true;
+ const o = new Out(); o.push([PROTOCOL_VERSION]);
+ await this.splitRange(0, this.set.size, { ts: MAX_TS, id: new Uint8Array(0) }, o);
+ return hexOf(o.bytes());
+ }
+ // One message in, one out. For the initiator the answer is null once the
+ // sets are reconciled; `have` are ids we hold that the other side lacks,
+ // `need` the ones it holds that we lack. The non-initiator (a relay, or
+ // a test playing one) always answers and collects nothing.
+ async reconcile(hex) {
+ const q = new In(bytesOf(hex));
+ const have = [], need = [];
+ this.lastTsIn = this.lastTsOut = 0;
+ const full = new Out(); full.push([PROTOCOL_VERSION]);
+ const version = q.byte();
+ if (version < 0x60 || version > 0x6f) throw new Error('negentropy: not a negentropy message');
+ if (version !== PROTOCOL_VERSION) {
+ if (this.initiator) throw new Error('negentropy: unsupported protocol version ' + (version - 0x60));
+ return { next: hexOf(full.bytes()), have, need };
+ }
+ const items = this.set.items, size = this.set.size;
+ let prevBound = { ts: 0, id: new Uint8Array(0) }, prevIndex = 0, skip = false;
+ while (q.left) {
+ const o = new Out();
+ const doSkip = () => { if (skip) { skip = false; this.putBound(o, prevBound); putVar(o, MODE.skip); } };
+ const bound = this.getBound(q);
+ const mode = getVar(q);
+ const lower = prevIndex;
+ let upper = this.set.lowerBound(prevIndex, size, bound);
+ if (mode === MODE.skip) skip = true;
+ else if (mode === MODE.fingerprint) {
+ const theirs = q.take(FINGERPRINT_SIZE);
+ const ours = await fingerprint(items, lower, upper);
+ if (cmpBytes(theirs, ours) !== 0) { doSkip(); await this.splitRange(lower, upper, bound, o); }
+ else skip = true;
+ } else if (mode === MODE.idList) {
+ const n = getVar(q);
+ const theirs = new Map();
+ for (let i = 0; i < n; i++) { const id = q.take(ID_SIZE); if (this.initiator) theirs.set(hexOf(id), id); }
+ if (this.initiator) {
+ skip = true;
+ for (let i = lower; i < upper; i++) {
+ const k = hexOf(items[i].id);
+ if (theirs.has(k)) theirs.delete(k); else have.push(k);
+ }
+ for (const k of theirs.keys()) need.push(k);
+ } else {
+ doSkip();
+ const ids = new Out(); let count = 0, endBound = bound;
+ for (let i = lower; i < upper; i++) {
+ if (this.overFrame(full.length + ids.length)) { endBound = items[i]; upper = i; break; }
+ ids.push(items[i].id); count++;
+ }
+ this.putBound(o, endBound); putVar(o, MODE.idList); putVar(o, count); o.push(ids.bytes());
+ full.push(o.bytes()); o.parts = []; o.length = 0;
+ }
+ } else throw new Error('negentropy: unexpected mode ' + mode);
+ if (this.overFrame(full.length + o.length)) {
+ // the frame is full: one fingerprint for everything left
+ const rest = await fingerprint(items, upper, size);
+ this.putBound(full, { ts: MAX_TS, id: new Uint8Array(0) }); putVar(full, MODE.fingerprint); full.push(rest);
+ break;
+ }
+ full.push(o.bytes());
+ prevIndex = upper; prevBound = bound;
+ }
+ return { next: full.length === 1 && this.initiator ? null : hexOf(full.bytes()), have, need };
+ }
+ // a range that differs: sixteen fingerprinted buckets, or the ids outright when small
+ async splitRange(lower, upper, upperBound, o) {
+ const items = this.set.items, n = upper - lower, buckets = 16;
+ if (n < buckets * 2) {
+ this.putBound(o, upperBound); putVar(o, MODE.idList); putVar(o, n);
+ for (let i = lower; i < upper; i++) o.push(items[i].id);
+ return;
+ }
+ const per = Math.floor(n / buckets), extra = n % buckets;
+ let cur = lower;
+ for (let b = 0; b < buckets; b++) {
+ const size = per + (b < extra ? 1 : 0);
+ const fp = await fingerprint(items, cur, cur + size);
+ cur += size;
+ const next = cur === upper ? upperBound : minimalBound(items[cur - 1], items[cur]);
+ this.putBound(o, next); putVar(o, MODE.fingerprint); o.push(fp);
+ }
+ }
+ overFrame(n) { return !!this.frameSizeLimit && n > this.frameSizeLimit - 200; }
+ // bounds: a timestamp as a delta from the last one (+1; 0 = infinity), then a prefix of an id
+ getBound(q) {
+ let ts = getVar(q);
+ ts = ts === 0 ? MAX_TS : ts - 1;
+ if (this.lastTsIn === MAX_TS || ts === MAX_TS) { this.lastTsIn = MAX_TS; ts = MAX_TS; }
+ else { ts += this.lastTsIn; this.lastTsIn = ts; }
+ const len = getVar(q);
+ if (len > ID_SIZE) throw new Error('negentropy: bound key too long');
+ return { ts, id: q.take(len) };
+ }
+ putBound(o, b) {
+ if (b.ts === MAX_TS) { this.lastTsOut = MAX_TS; putVar(o, 0); }
+ else { putVar(o, b.ts - this.lastTsOut + 1); this.lastTsOut = b.ts; }
+ putVar(o, b.id.length); o.push(b.id);
+ }
+}
+// the shortest bound that separates two neighbouring items
+function minimalBound(prev, cur) {
+ if (cur.ts !== prev.ts) return { ts: cur.ts, id: new Uint8Array(0) };
+ let shared = 0;
+ while (shared < ID_SIZE && cur.id[shared] === prev.id[shared]) shared++;
+ return { ts: cur.ts, id: cur.id.subarray(0, shared + 1) };
+}
diff --git a/src/nostr.js b/src/nostr.js
index b76e865..8dcab01 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -18,6 +18,7 @@ import { normalizeURL } from 'nostr-tools/utils';
import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
import { SimplePool } from 'nostr-tools/pool';
import { dlog } from './debug.js';
+import { NegSet, Negentropy } from './negentropy.js';
import { randomBytes, bytesToHex } from '@noble/hashes/utils';
import { base64urlnopad } from '@scure/base';
@@ -365,6 +366,99 @@ export async function queryOn(relays, filter, maxWait = 1500) {
}
// Several filters in ONE REQ per relay (NIP-01 allows a list) — one
// subscription against the relay's REQ budget instead of one per filter.
+// ---- negentropy (NIP-77): fetch only what we lack ---------------------------
+// A relay that speaks it (strfry with it enabled: relay.coinos.io does;
+// nos.lol, damus and primal answer "negentropy disabled") compares the ids
+// it holds for a filter with the ids we hold and tells us the difference —
+// a few hundred bytes — instead of re-sending the newest N events every
+// time we ask. The events we lack are then fetched by id. A relay that
+// doesn't speak it is remembered for a day, so the plain query goes
+// straight out there next time rather than after a wasted socket.
+const NEG_OFF_KEY = 'coinos-neg-off';
+const NEG_OFF_MS = 24 * 60 * 60_000;
+const negOff = new Map(); // normalized url -> until
+try {
+ for (const [u, until] of Object.entries(JSON.parse(localStorage.getItem(NEG_OFF_KEY) || '{}'))) if (until > Date.now()) negOff.set(u, until);
+} catch {}
+function negRefused(url) {
+ negOff.set(relayKey(url), Date.now() + NEG_OFF_MS);
+ try { localStorage.setItem(NEG_OFF_KEY, JSON.stringify(Object.fromEntries([...negOff].slice(-100)))); } catch {}
+}
+export const negentropyOff = (url) => (negOff.get(relayKey(url)) || 0) > Date.now();
+// Reconcile `items` ([[created_at, id], ...]: the events we hold) with one
+// relay under `filter`. Resolves { need, have } — the ids it holds that we
+// don't, and ours it lacks — or null when it can't be done there: the relay
+// doesn't speak it (a NOTICE, a CLOSED, a socket shut on the opening
+// message, or silence — remembered for a day), refuses this query (NEG-ERR
+// blocked/closed — not remembered), is sick, or we're offline. Its own
+// socket: the pool's parser has no ear for NEG-* frames.
+export async function reconcileOn(url, filter, items, { timeout = 8000 } = {}) {
+ const n = relayKey(url);
+ if (sick.has(n) || negentropyOff(url) || offline()) return null;
+ let ws;
+ try { ws = new WebSocket(url); } catch { return null; }
+ const neg = new Negentropy(new NegSet(items));
+ const id = 'neg' + Math.random().toString(36).slice(2, 10);
+ return new Promise((resolve) => {
+ let done = false, heard = false;
+ const need = [], have = [];
+ const finish = (r, refused) => {
+ if (done) return;
+ done = true; clearTimeout(t);
+ if (refused) negRefused(url);
+ try { if (ws.readyState === 1) ws.send(JSON.stringify(['NEG-CLOSE', id])); } catch {}
+ try { ws.close(); } catch {}
+ resolve(r);
+ };
+ const t = setTimeout(() => finish(null, !heard), timeout);
+ ws.onerror = () => finish(null, false);
+ ws.onclose = () => finish(null, !heard && !offline() && !hiddenTab());
+ ws.onopen = async () => {
+ try { ws.send(JSON.stringify(['NEG-OPEN', id, filter, await neg.initiate()])); } catch { finish(null, false); }
+ };
+ ws.onmessage = async (e) => {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ if (!Array.isArray(m)) return;
+ if (m[0] === 'NEG-MSG' && m[1] === id) {
+ heard = true;
+ try {
+ const r = await neg.reconcile(m[2]);
+ need.push(...r.need); have.push(...r.have);
+ if (r.next) ws.send(JSON.stringify(['NEG-MSG', id, r.next]));
+ else finish({ need, have }, false);
+ } catch (err) { dlog('nostr: negentropy with ' + url + ' failed: ' + (err?.message || err)); finish(null, false); }
+ } else if (m[0] === 'NEG-ERR' && m[1] === id) { dlog('nostr: negentropy refused by ' + url + ': ' + m[2]); finish(null, false); }
+ else if (m[0] === 'NOTICE' && /negentropy|NEG-OPEN|bad msg/i.test(String(m[1] || ''))) finish(null, true);
+ else if (m[0] === 'CLOSED' && m[1] === id) finish(null, true);
+ };
+ });
+}
+// What `filter` matches on these relays, each event once, verified: by
+// reconciliation where a relay speaks it (only the events we don't hold
+// come, by id) and by the plain query where it doesn't. `items` are the
+// events held, [[created_at, id], ...].
+export async function syncOn(relays, filter, items, { maxWait = 5000 } = {}) {
+ probeRelays(relays);
+ const live = readRelays(relays);
+ if (!live.length) return [];
+ const plain = [], byRelay = new Map(), seen = new Set();
+ await Promise.all(live.map(async (url) => {
+ const r = await reconcileOn(url, filter, items).catch(() => null);
+ if (!r) { plain.push(url); return; }
+ for (const id of r.need) {
+ if (seen.has(id)) continue; // one copy is enough: the first relay that has it
+ seen.add(id);
+ if (!byRelay.has(url)) byRelay.set(url, []);
+ byRelay.get(url).push(id);
+ }
+ }));
+ const jobs = [];
+ if (plain.length) jobs.push(queryOn(plain, filter, maxWait));
+ for (const [url, ids] of byRelay) for (let i = 0; i < Math.min(ids.length, 1000); i += 200) jobs.push(queryOn([url], { ids: ids.slice(i, i + 200) }, maxWait));
+ const out = new Map();
+ for (const evs of await Promise.all(jobs)) for (const e of evs || []) if (e && e.id && !out.has(e.id)) out.set(e.id, e);
+ return [...out.values()];
+}
export async function queryManyOn(relays, filters, maxWait = 1500) {
probeRelays(relays);
try {
diff --git a/src/style.css b/src/style.css
index 4a9b191..9547ab0 100644
--- a/src/style.css
+++ b/src/style.css
@@ -30,6 +30,12 @@
--accent-fg: #ffffff; /* text on accent */
--accent-ink: #000000;
--accent-soft: #ececef;
+ /* a button's fill (primary, chosen chip or tab): the accent in light; in
+ dark a lifted grey, not a white slab (Adam, 2026-10-18: "Probably
+ shouldn't have white buttons in dark mode") */
+ --fill: var(--accent);
+ --fill-hover: var(--accent-hover);
+ --fill-fg: var(--accent-fg);
--green: #14935a;
--green-soft: #e7f6ee;
--green-line: #c4e7d4;
@@ -65,6 +71,9 @@
--accent-fg: #0f1216; /* text on accent */
--accent-ink: #e7e9ee;
--accent-soft: #262b33;
+ --fill: #353b45;
+ --fill-hover: #404753;
+ --fill-fg: #f3f5f8;
--green: #2dbd80;
--green-soft: #14271d;
--green-line: #235340;
@@ -92,6 +101,12 @@ html {
one last time" after a switch. Stable gutter = no reflow when content
grows. */
scrollbar-gutter: stable;
+ /* Nothing ever scrolls the page sideways. Anything wider than the screen
+ (a strip of chips, a stray wide row) would otherwise let an iPhone
+ pinch the page out and pan to it. (On the root element, not body: a
+ body that clips becomes the scroll container a sticky rail measures
+ against, and the rail would stop sticking.) */
+ overflow-x: hidden;
}
html, body {
@@ -224,8 +239,8 @@ button, .btn {
button:hover { background: var(--surface2); }
button:active { transform: translateY(1px); }
button:disabled { opacity: .5; cursor: not-allowed; }
-.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
-.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.btn-primary { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); }
+.btn-primary:hover { background: var(--fill-hover); border-color: var(--fill-hover); }
.btn-ghost { background: transparent; }
.btn-sm { padding: 7px 11px; font-size: 13px; border-radius: 9px; }
.btn-sm.on { border-color: var(--accent); color: var(--accent); }
@@ -285,6 +300,12 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; padding: 9px 14px; background: var(--paper); }
.seg button.active { background: var(--accent-soft); color: var(--accent-ink); }
+/* The profile's Posts / Replies tabs sit right on white cards: a soft-grey
+ active tab beside a white one read as the WHITE one being selected (its
+ colour matched the posts under it). So these take the feed chips' look:
+ the chosen tab solid accent, the other one quiet (Adam, 2026-10-16). */
+.prof-tabs button { background: var(--surface2); color: var(--muted); }
+.prof-tabs button.active { background: var(--fill); color: var(--fill-fg); }
/* ---- address / qr ---- */
.qr { width: 220px; height: 220px; display: block; border-radius: 12px; border: 1px solid var(--line); }
@@ -714,7 +735,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.pos-big { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: -.5px; }
.pos-tips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.pos-tip { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; }
-.pos-tip.on { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
+.pos-tip.on { border-color: var(--fill); background: var(--fill); color: var(--fill-fg); }
.pos-tip.on .muted { color: inherit; opacity: .8; }
.pos-tip-label { font-size: 20px; font-weight: 600; }
.pos-total { font-size: 22px; font-weight: 600; margin-top: 4px; }
@@ -723,14 +744,12 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.pos-sale.faint { opacity: .55; }
.pos-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.pos-key { padding: 14px 0; font-size: 22px; font-weight: 600; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
-.pos-key:active { background: var(--accent); color: var(--accent-fg); }
+.pos-key:active { background: var(--fill); color: var(--fill-fg); }
.pos-key.del { font-weight: 400; }
/* the feeds row: pills that scroll sideways, the one on screen filled */
.feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.feed-chips::-webkit-scrollbar { display: none; }
/* with a mouse: a thin bar says the strip scrolls, and it drags (app.js) */
-/* grabbing the feed to scroll it (app.js) */
-.grab-scrolling, .grab-scrolling * { cursor: grabbing !important; user-select: none !important; }
@media (hover: hover) and (pointer: fine) {
.feed-chips { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; padding-bottom: 6px; cursor: grab; }
.feed-chips::-webkit-scrollbar { display: block; height: 6px; }
@@ -739,7 +758,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.feed-chips.dragging .feed-chip { pointer-events: none; }
}
.feed-chip { flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface2); color: var(--muted); border-radius: 999px; padding: 5px 12px; font-size: 14px; line-height: 1.2; cursor: pointer; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
-.feed-chip.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
+.feed-chip.on { background: var(--fill); color: var(--fill-fg); border-color: var(--fill); }
.feed-chip.add { font-weight: 600; padding: 5px 11px; }
.note-act {
background: none; border: 0; padding: 4px 6px; margin: 0; line-height: 1;
@@ -942,7 +961,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
left: 50%; transform: translateX(-50%);
z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px;
- background: var(--accent); color: var(--accent-fg); border: none;
+ background: var(--fill); color: var(--fill-fg); border: none;
font-size: 16px; font-weight: 650; cursor: pointer;
touch-action: none; user-select: none; -webkit-user-select: none; /* it swipes away */
box-shadow: 0 3px 12px rgba(0, 0, 0, .28);
@@ -951,7 +970,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
/* the generic button hover (a light surface) and press (a 1px drop, which
REPLACED the centring transform and threw the pill half its width to the
right, out from under the pointer, so the tap never landed) stay off it */
-.feed-new-pill:hover { background: var(--accent-hover); color: var(--accent-fg); }
+.feed-new-pill:hover { background: var(--fill-hover); color: var(--fill-fg); }
.feed-new-pill:active { transform: translate(-50%, 1px); }
/* the count bumps each time it changes: a fresh node per value replays it */
.feed-new-pill .n { display: inline-block; min-width: 1.1em; text-align: center; animation: pill-bump .32s cubic-bezier(.2,.9,.3,1.4); }
@@ -1177,7 +1196,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.hat-demo { position: relative; display: inline-flex; width: 34px; height: 34px; flex-shrink: 0; margin-top: 12px; }
.hat-demo-head { width: 100%; height: 100%; border-radius: 50%; background: var(--accent-soft); }
.hat-btn { white-space: nowrap; flex-shrink: 0; }
-.hat-btn.wearing { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
+.hat-btn.wearing { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); }
.hat-btn.confirm { border-color: var(--green); color: var(--green); }
/* a small centered confirm popup over a dimmed page */
@@ -1455,7 +1474,13 @@ body::after {
.social-count.on { color: var(--ink); border-bottom-color: var(--accent); }
/* ---- feed media views: All / Images / Videos, the grid, the pager ---- */
-.media-modes { gap: 6px; }
+/* The view strip scrolls sideways like the feed chips above it: five
+ chips no longer fit a phone (Conversations joined them), and a flex row
+ that neither wraps nor scrolls made the page 42px wider than the screen
+ — on an iPhone that is a page you can pinch out of and pan, a blank
+ strip down the right (Adam, 2026-10-16). */
+.media-modes { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
+.media-modes::-webkit-scrollbar { display: none; }
.media-modes .feed-chip { padding: 4px 12px; font-size: 13px; }
.article-list { gap: 10px; }
.article-list > .quote-card { margin-top: 0; }
@@ -1533,6 +1558,7 @@ body::after {
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; overflow-wrap: anywhere;
}
.mp-caption.open { -webkit-line-clamp: unset; max-height: 40vh; overflow-y: auto; }
+.mp-caption a { color: #fff; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(255, 255, 255, .5); }
.mp-counts { display: flex; flex-direction: column; align-items: center; gap: 14px; flex: 0 0 auto; }
.mp-counts button {
display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0; margin: 0; border: 0;
@@ -1555,6 +1581,17 @@ body::after {
}
.mp-sound { font-size: 15px; }
.mp-sound .off { display: none; }
+.mp-top-r { display: flex; align-items: center; gap: 8px; }
+.mp-fit { height: 40px; padding: 0 14px; font-size: 14px; font-weight: 600; border-radius: 999px; border: 0; color: #fff; background: rgba(255, 255, 255, .16); }
+.media-pager.quiet .mp-top .mp-fit { pointer-events: auto; }
+/* a post out of the carousel: its clip not ready to play yet (mp-wait), or
+ the wrong way round for the screen (mp-off). Parked far above the screen
+ (fixed: out of the scroller's flow and its snap points, no height to
+ count) rather than display:none, so its box isn't a zero rect at the
+ origin that a "what's at the top left" lookup takes for the slide on
+ screen; its clip's poster still loads there. */
+.mp-group.mp-wait, .mp-group.mp-off { position: fixed; top: -400vh; left: 0; height: 0; visibility: hidden; pointer-events: none; }
+/* over the Videos grid: which shapes */
/* A mouse's way through the pager: arrows, where there is a pointer to
hover with (a phone swipes). Faint until the pointer is over the pager;
the sideways pair only when the post has a picture that way. */
@@ -1642,9 +1679,12 @@ body::after {
.media-pager { --mp-seek-lift: calc(env(safe-area-inset-bottom, 0px) + 32px); }
.mp-seek {
position: absolute; left: 16px; right: 16px; bottom: var(--mp-seek-lift); z-index: 3; height: 28px;
- display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden;
+ display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden; opacity: 0;
}
-.mp-seek.ready { visibility: visible; }
+.mp-seek.ready { visibility: visible; opacity: 1; transition: opacity .18s; }
+/* only the clip on screen's bar, and not while the pager is in motion (a
+ finger, a glide, a flight): it goes at once and fades back in on landing */
+.mp-slide:not(.mp-cur) .mp-seek, .media-pager.moving .mp-seek { opacity: 0; transition: none; pointer-events: none; }
.mp-seek-track { position: relative; width: 100%; height: 3px; border-radius: 3px; background: rgba(255, 255, 255, .28); transition: height .15s; }
.mp-seek-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: inherit; background: #fff; }
.mp-seek-fill::after {
@@ -1797,6 +1837,11 @@ body::after {
.gif-tile video, .gif-tile img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.gif-note { display: flex; justify-content: center; padding: 32px 0; }
.note-reposted { margin-bottom: 2px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* a reply shown with the post it answers above it (Conversations, a profile's Replies) */
+.note-context { margin-bottom: 6px; }
+.note-context-label { margin-bottom: 2px; }
+.note-context .quote-card { margin-top: 0; }
+.note-context .quote-card .note-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden; }
/* attach + GIF inside the composer's field, bottom right; the field keeps
room under its text so a long post never runs beneath them */
.compose-box { position: relative; }
@@ -1816,3 +1861,9 @@ body::after {
/* Always visible, including when the media controls are tucked away. */
.mp-position { position: absolute; top: calc(env(safe-area-inset-top, 0px) + 16px); left: 50%; transform: translateX(-50%); padding: 5px 10px; border-radius: 20px; background: #0009; color: #fff; font-size: 13px; font-variant-numeric: tabular-nums; pointer-events: none; z-index: 5; }
+/* the counter shows while the pager is in motion and fades out a moment
+ after it settles (the animation restarts each time `moving` comes off) */
+.media-pager .mp-position { animation: mp-position-out .4s 1.2s both; }
+.media-pager.moving .mp-position { animation: none; opacity: 1; }
+@keyframes mp-position-out { from { opacity: 1; } to { opacity: 0; } }
+.media-pager.few .mp-position { visibility: hidden; }
diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js
index 6d34db6..c21289c 100644
--- a/src/thread-prefetch.js
+++ b/src/thread-prefetch.js
@@ -1,5 +1,9 @@
// Dwell is measured on mounted rows, including posts taller than the viewport.
// Sampling also catches fast movement within a large post (IO alone cannot).
+// A post read for half a second is a post that may be opened: its thread is
+// fetched then (it was 1.2 s, which a quick reader beat to the tap), one at
+// a time, a dozen a minute.
+const DWELL_MS = 600, PER_MINUTE = 12;
export function createThreadPrefetch({ load, key, cached, doc = globalThis.document,
nav = globalThis.navigator, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) {
const seeds = new Map(), dwell = new Map();
@@ -23,12 +27,12 @@ export function createThreadPrefetch({ load, key, cached, doc = globalThis.docum
if (!d || Math.abs(r.top - d.top) > 25) d = { since: time, top: r.top };
d.top = r.top;
dwell.set(id, d);
- if (time - d.since >= 1200) ready.push(seed);
+ if (time - d.since >= DWELL_MS) ready.push(seed);
}
for (const id of seeds.keys()) if (!mounted.has(id)) seeds.delete(id);
for (const id of dwell.keys()) if (!visible.has(id)) dwell.delete(id);
starts = starts.filter((t) => time - t < 60000);
- if (!busy && starts.length < 6 && ready.length) {
+ if (!busy && starts.length < PER_MINUTE && ready.length) {
const seed = ready[0], token = generation;
busy = true; starts.push(time);
// The loader inserts synchronously, so clicks share its in-flight work.
diff --git a/src/video-compress.js b/src/video-compress.js
index 53d4eb0..8fe0856 100644
--- a/src/video-compress.js
+++ b/src/video-compress.js
@@ -52,3 +52,66 @@ export async function compressVideo(file, onProgress = () => {}) {
try { input.dispose?.(); } catch {}
}
}
+
+// A long video as an HLS stream (Adam, 2026-10-18; jb55: "you should use
+// hls (m3u8 ext) for long videos"): a 720p and a 360p rendition, the
+// player taking the one the connection carries, so an hour-long clip
+// neither stalls on mobile data nor costs the whole file to watch a
+// minute of it. Made here, like the shrinking above, in 4-second segments,
+// each rendition (and the sound) one file read in ranges — a handful of
+// uploads, not hundreds. The files keep the names the playlists give them;
+// uploadStream puts them on a host and the playlists on after them.
+// Anything short of a stream — a short clip, no WebCodecs, an error — is
+// null, and the video goes up as one file as before.
+export const STREAM_MIN_S = 90;
+export async function streamVideo(file, onProgress = () => {}) {
+ if (!file || !/^video\//.test(file.type || '')) return null;
+ if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return null;
+ let mb;
+ try { mb = await import('mediabunny'); } catch { return null; }
+ const { Input, Output, Conversion, BlobSource, BufferTarget, PathedTarget, HlsOutputFormat, CmafOutputFormat, ALL_FORMATS } = mb;
+ if (!HlsOutputFormat || !PathedTarget) return null;
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
+ try {
+ const video = await input.getPrimaryVideoTrack();
+ if (!video) return null;
+ const seconds = await input.computeDuration();
+ if (!(seconds >= STREAM_MIN_S)) return null;
+ const w = video.displayWidth, h = video.displayHeight, short = Math.min(w, h);
+ const edge = (n) => (w >= h ? { height: Math.min(n, short) } : { width: Math.min(n, short) });
+ const rungs = [{ ...edge(SHORT_EDGE), bitrate: VIDEO_BPS }, { ...edge(360), bitrate: 800_000 }];
+ const files = new Map(); // path -> { target, mime }
+ const target = new PathedTarget('master.m3u8', (req) => {
+ const t = new BufferTarget();
+ files.set(req.path, { target: t, mime: req.mimeType });
+ return t;
+ });
+ const output = new Output({
+ target,
+ format: new HlsOutputFormat({
+ segmentFormat: new CmafOutputFormat(), singleFilePerPlaylist: true, targetDuration: 4,
+ getPlaylistPath: ({ n }) => `p${n}.m3u8`,
+ getSegmentPath: ({ playlist }) => `s${playlist.n}.mp4`, // (a host serves .mp4 to anyone; .m4s it may not take)
+ }),
+ });
+ const conversion = await Conversion.init({
+ input, output,
+ video: rungs.map((r) => ({ ...r, codec: 'avc', keyFrameInterval: 4, forceTranscode: true })),
+ });
+ if (!conversion.isValid) return null;
+ conversion.onProgress = (p) => { try { onProgress(p); } catch {} };
+ await conversion.execute();
+ const out = new Map();
+ for (const [path, { target: t, mime }] of files) {
+ if (!t.buffer) return null;
+ out.set(path, new File([t.buffer], path, { type: /m3u8$/.test(path) ? 'application/vnd.apple.mpegurl' : mime || 'video/mp4' }));
+ }
+ if (!out.has('master.m3u8')) return null;
+ const dim = w >= h ? `${Math.round(w * Math.min(1, SHORT_EDGE / short))}x${Math.min(SHORT_EDGE, short)}` : `${Math.min(SHORT_EDGE, short)}x${Math.round(h * Math.min(1, SHORT_EDGE / short))}`;
+ return { files: out, master: 'master.m3u8', seconds, dim };
+ } catch {
+ return null;
+ } finally {
+ try { input.dispose?.(); } catch {}
+ }
+}
diff --git a/tools/deepl-translate.test.js b/tools/deepl-translate.test.js
deleted file mode 100644
index 39975d0..0000000
--- a/tools/deepl-translate.test.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// names/translate.js translateDeepl: the reader's key goes to the right
-// DeepL host as a DeepL-Auth-Key header, only the prose is sent (links and
-// tags kept), the detected language comes back, and DeepL's refusals turn
-// into statuses the client can word (401 bad key, 402 quota).
-// Run: bun test tools/deepl-translate.test.js
-import { test, expect } from 'bun:test';
-import { translateDeepl } from '../names/translate.js';
-
-const fake = (status = 200, seen = []) => async (url, init) => {
- const body = JSON.parse(init.body);
- seen.push({ url, auth: init.headers.authorization, body });
- if (status !== 200) return new Response('{}', { status });
- return new Response(JSON.stringify({ translations: body.text.map((t) => ({ text: '[it] ' + t, detected_source_language: 'EN' })) }), { status: 200 });
-};
-
-test('a free key goes to api-free, as DeepL-Auth-Key, prose only', async () => {
- const seen = [];
- const out = await translateDeepl('Good morning everyone, look at this https://example.com/a.png #nostr', 'it', 'abcdef01-2345-6789-abcd-ef0123456789:fx', fake(200, seen));
- expect(seen[0].url).toBe('https://api-free.deepl.com/v2/translate');
- expect(seen[0].auth).toBe('DeepL-Auth-Key abcdef01-2345-6789-abcd-ef0123456789:fx');
- expect(seen[0].body.target_lang).toBe('IT');
- expect(seen[0].body.text.join(' ')).not.toContain('https://');
- expect(out.source).toBe('en');
- expect(out.text).toContain('[it] ');
- expect(out.text).toContain('https://example.com/a.png');
- expect(out.text).toContain('#nostr');
-});
-test('a paid key goes to api.deepl.com; English means EN-US', async () => {
- const seen = [];
- await translateDeepl('Buongiorno a tutti, come state oggi?', 'en', 'abcdef01-2345-6789-abcd-ef0123456780', fake(200, seen));
- expect(seen[0].url).toBe('https://api.deepl.com/v2/translate');
- expect(seen[0].body.target_lang).toBe('EN-US');
-});
-test('refusals: bad key 401, quota 402', async () => {
- await expect(translateDeepl('Hello there my friends, how are you', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(403))).rejects.toMatchObject({ status: 401 });
- await expect(translateDeepl('Hello there my friends, how are you all', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(456))).rejects.toMatchObject({ status: 402 });
- await expect(translateDeepl('Hello', 'it', 'not a key')).rejects.toMatchObject({ status: 401 });
-});
diff --git a/tools/feed-appetite-test.js b/tools/feed-appetite-test.js
new file mode 100644
index 0000000..b79a2a9
--- /dev/null
+++ b/tools/feed-appetite-test.js
@@ -0,0 +1,159 @@
+// The feed pages to the reader's appetite. Reading at an ordinary pace it
+// shows ten more posts at a time, and asks the relays for older ones while
+// a page or two is still in hand — never once the reader has run out. A
+// reader flying through (five screens in ten seconds, by their own hand)
+// gets bigger pages, from further above the foot of the feed; standing
+// still a while brings the ordinary page back. A page held still under an
+// insert above is not travel.
+// Run: bun tools/feed-appetite-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (i) => finalizeEvent({ kind: 1, created_at: now - 60 * i, tags: [], content: 'post number ' + i + ' of the long timeline' }, SK);
+const cached = Array.from({ length: 60 }, (_, i) => post(i)); // in the feed cache
+const older = Array.from({ length: 300 }, (_, i) => post(60 + i)); // on the relays, for the asks
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// The relays, answered in the page: an ask for older posts (a filter with
+// `until` for the author) gets the thirty newest at or before it, after a
+// moment, then EOSE. Every ask is logged with the feed's state at that moment.
+await page.evaluateOnNewDocument((fx) => {
+ window.__asks = []; window.__steps = [];
+ window.__snap = () => { const f = document.querySelector('.notes-feed'); return { t: Date.now(), shown: +(f?.dataset.shown || 0), held: +(f?.dataset.held || 0), remaining: Math.round(document.documentElement.scrollHeight - window.scrollY - window.innerHeight) }; };
+ const Real = window.WebSocket;
+ const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); };
+ const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); };
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m.slice(2).find((f) => f.until && (f.kinds || []).includes(1) && (f.authors || []).includes(fx.author));
+ if (f) {
+ if (!window.__asks.some((a) => a.until === f.until)) window.__asks.push({ ...window.__snap(), until: f.until, limit: f.limit || 0 });
+ const evs = fx.older.filter((e) => e.created_at <= f.until).slice(0, f.limit || 30);
+ setTimeout(() => { for (const ev of evs) send1(ws, m[1], ev); eose(ws, m[1]); }, 300);
+ return;
+ }
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, { author: AUTHOR, older });
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
+const snap = () => page.evaluate(() => window.__snap());
+const steps = () => page.evaluate(() => window.__steps);
+const asks = () => page.evaluate(() => window.__asks);
+// a scroll by the reader's own hand: an input stamp, then the page moves
+const handScroll = (dy) => page.evaluate((dy) => { window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); window.scrollBy({ top: dy, behavior: 'instant' }); }, dy);
+const H = 500;
+
+try {
+ await page.setViewport({ width: 390, height: H, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Long Timeline', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, cached]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the cached posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 5, null, 20000));
+ // every change of the page count, with the feed's state at that moment
+ await page.evaluate(() => {
+ const f = document.querySelector('.notes-feed');
+ let last = +f.dataset.shown;
+ new MutationObserver(() => { const n = +(document.querySelector('.notes-feed')?.dataset.shown || 0); if (n !== last) { window.__steps.push({ ...window.__snap(), from: last }); last = n; } })
+ .observe(document.querySelector('#app') || document.body, { attributes: true, subtree: true, attributeFilter: ['data-shown'] });
+ });
+ const s0 = await snap();
+ check('it opens on a page of ten, with the cached posts in hand', s0.shown === 10 && s0.held >= 30, JSON.stringify(s0)); // (the feed cache keeps thirty)
+
+ console.log('\n[reading at an ordinary pace]');
+ // a few posts every so often, no hand on it (the browser's own scroll: the
+ // trail counts only travel within a moment of an input)
+ for (let i = 0; i < 60; i++) {
+ await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' }));
+ await sleep(600);
+ const s = await snap();
+ if (s.shown >= 60) break;
+ }
+ await waitFor(() => +(document.querySelector('.notes-feed')?.dataset.shown || 0) >= 60, null, 8000);
+ const slow = await steps();
+ check('pages of ten', slow.length >= 3 && slow.every((s) => s.shown - s.from <= 10), slow.map((s) => s.from + '→' + s.shown).join(' '));
+ const a = await asks();
+ check('the relays were asked for older posts while posts were still in hand', a.length >= 1 && a[0].shown < a[0].held, JSON.stringify(a[0]));
+ const at60 = slow.find((s) => s.shown >= 60);
+ check('…so reaching the end of what was held found more already there', !!at60 && at60.held > at60.shown, JSON.stringify(at60));
+ check('the ordinary pace never asked for more than a page at a time', slow.every((s) => s.shown - s.from <= 10));
+
+ console.log('\n[flying through]');
+ await sleep(10_500); // the trail forgets the slow spell
+ await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await sleep(600);
+ const before = await steps();
+ const sB = await snap();
+ // eleven screens in half a second, by hand; a breath; eleven more
+ for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); }
+ await sleep(1500);
+ for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); }
+ await waitFor((n) => window.__steps.length > n && window.__steps.slice(n).some((s) => s.shown - s.from >= 30), before.length, 6000);
+ await sleep(1500);
+ const fast = (await steps()).slice(before.length);
+ const big = fast.find((s) => s.shown - s.from >= 20);
+ check('a bigger page at once', !!big, fast.map((s) => s.from + '→' + s.shown).join(' ') || 'no page');
+ // (a page begun mid-flight is sized to the screens flown so far; the ones after are the full three)
+ check('…growing to three pages\' worth', fast.some((s) => s.shown - s.from >= 30), fast.map((s) => s.shown - s.from).join(' '));
+ check('…asked for from further above the foot than the ordinary lead', !!big && big.remaining > Math.max(3000, 4 * H), big ? big.remaining + ' px left below' : '');
+ const a2 = (await asks()).slice(a.length);
+ check('…and the relays asked for more per round trip', a2.some((x) => x.limit >= 60), a2.map((x) => x.limit).join(' ') || 'no ask');
+
+ console.log('\n[standing still again]');
+ await sleep(10_500);
+ const before3 = await steps();
+ for (let i = 0; i < 30; i++) {
+ await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' }));
+ await sleep(600);
+ if ((await steps()).length > before3.length) break;
+ }
+ await sleep(500);
+ const calm = (await steps()).slice(before3.length);
+ check('the ordinary page is back', calm.length >= 1 && calm.every((s) => s.shown - s.from <= 10), calm.map((s) => s.from + '→' + s.shown).join(' ') || 'no page');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the feed pages to the reader\'s appetite' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js
new file mode 100644
index 0000000..e762d1a
--- /dev/null
+++ b/tools/feed-conversations-test.js
@@ -0,0 +1,133 @@
+// Conversations: a view of any feed (the chip beside Notes, Images, Videos
+// and Articles) — the replies its people write, each under the post it
+// answers (Amethyst's Conversations). Notes keeps only their posts; a
+// profile's Replies tab shows the same context; the view persists across
+// feeds and reloads like the other views.
+// Run: bun tools/feed-conversations-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk = SKA, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const B1 = post('bob root here, a question for everyone', 400, SKB);
+const A1 = post('alice root post about nothing', 300);
+const MISSING = 'f'.repeat(64);
+const R1 = post('alice answers bob right here', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const R2 = post('alice replies to a post nobody has', 100, SKA, [['e', MISSING, '', 'root']]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const rowIds = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')));
+const contextOf = (id) => page.evaluate((id) => {
+ const r = document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]');
+ const c = r && r.querySelector('.note-context');
+ return c ? { of: c.getAttribute('data-context-of'), text: c.innerText, card: !!c.querySelector('.quote-card.clickable') } : null;
+}, id);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, alice, bob, roots, replies]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(roots));
+ localStorage.setItem(k + ':feedNotes:following~replies', JSON.stringify(replies));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Replier', about: 'alice bio here', t: Date.now() }, [bob]: { name: 'Bob Asker', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, [A1, B1], [R1, R2]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('Following paints their posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
+ const following = await rowIds();
+ check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(','));
+ const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' '));
+ check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos articles=Articles', await modes());
+ check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent)))));
+
+ console.log('\n[Conversations]');
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('the replies paint', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000));
+ const convo = await rowIds();
+ check('…only replies, newest first', JSON.stringify(convo) === JSON.stringify([R2.id, R1.id]), convo.map((x) => x.slice(0, 6)).join(','));
+ check('the view chip is on, the feed chip still Following', /replies=Conversations\*/.test(await modes()) && await page.$eval('.feed-chip[data-key="chip:following"]', (e) => e.classList.contains('on')));
+ const c1 = await contextOf(R1.id);
+ check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1));
+ check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText))));
+ check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text);
+ // the reply's own tap (its text, not the card above it): its thread, at the reply
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id);
+ check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000));
+ check('…with Bob\'s post as its root', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('.feed-chips'), B1.id, 4000));
+ await page.goBack(); await sleep(800);
+ check('Back lands on the Conversations view again', await waitFor(() => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && !!document.querySelector('.note-post[data-zap-post]'), null, 6000));
+ // the context card's tap: the parent's own thread
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context .quote-card').click(), R1.id);
+ check('tapping the post it answers opens that post, at the top', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('[data-focus-note]') && !document.querySelector('.feed-chips'), B1.id, 8000));
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[a profile\'s Replies tab]');
+ await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-avatar').click(), R1.id);
+ check('Alice\'s profile opens', await waitText('alice bio here', 8000));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="replies"]').click());
+ check('her Replies tab lists the reply', await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), R1.id, 8000));
+ const pc = await contextOf(R1.id);
+ check('…under the post it answers', !!pc && pc.of === B1.id && /bob root here/.test(pc.text), JSON.stringify(pc));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="posts"]').click());
+ await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), A1.id, 8000);
+ check('her Posts tab shows no context block', (await page.$$('.notes-feed .note-context')).length === 0);
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[the view on another feed]');
+ await page.evaluate(() => document.querySelector('.feed-chip.add').click());
+ check('the editor has no replies toggle (the view is the chip)', await waitFor(() => !!document.querySelector('.feed-interests-toggle'), null, 4000) && !(await page.$('.feed-replies-toggle')));
+ await page.evaluate(() => {
+ const name = document.querySelector('.chat-page input[type=text]'); name.value = 'Alice talks'; name.dispatchEvent(new Event('input', { bubbles: true }));
+ const follows = [...document.querySelectorAll('label')].find((l) => /Everyone I follow/.test(l.textContent)).querySelector('input'); follows.click();
+ });
+ await click('save'); await sleep(600);
+ check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000));
+ check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…with no context blocks', (await page.$$('.notes-feed .note-context')).length === 0);
+ await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click());
+ check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got[0] === ids[0]; }, [R2.id], 8000));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('a reload opens on the view last chosen, from its own cache', await waitFor((ids) => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')).includes(ids[0]), [R1.id], 20000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ conversations view: the replies they write, under what they answer' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 2c84b14..58e942b 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -126,10 +126,16 @@ try {
await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
console.log('\n[the switch]');
- check('All / Images / Videos / Articles sit under the chips, All on', await page.evaluate(() =>
- [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos,Articles'));
+ check('Notes / Conversations / Images / Videos / Articles sit under the chips, Notes on', await page.evaluate(() =>
+ [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Articles'));
+ // five chips are wider than a phone: the strip scrolls sideways, the page does not get wider
+ check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => {
+ const s = document.querySelector('.media-modes');
+ return s.scrollWidth > s.clientWidth && getComputedStyle(s).overflowX === 'auto' && document.documentElement.scrollWidth <= innerWidth;
+ }), await page.evaluate(() => 'page ' + document.documentElement.scrollWidth + ' of ' + innerWidth));
console.log('\n[Images: a grid]');
+ await page.evaluate(() => localStorage.setItem('coinos-clips-fit', 'all')); // every shape: these pictures are wide (the Tall only filter is Videos' to show)
await mode('images');
check('the feed becomes a grid of its pictures, one tile each', await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 3, 8000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length)));
@@ -156,6 +162,7 @@ try {
check('Back closes the pager, leaving the grid', await page.evaluate(() => !document.querySelector('.media-pager') && !!document.querySelector('.media-grid')));
check('...and the page scrolls again', await page.evaluate(() => !document.documentElement.classList.contains('no-scroll')));
+ await page.evaluate(() => localStorage.removeItem('coinos-clips-fit')); // the screen's way round again
console.log('\n[the big clip in the feed]');
await mode('all'); await sleep(800);
await page.evaluate(() => { const v = [...document.querySelectorAll('.note-video-box video')].find((x) => /c\.mp4/.test(x.getAttribute('src') || x.getAttribute('data-lazy-src') || '')); v.scrollIntoView({ block: 'center' }); });
diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js
index 29e4934..3debd8a 100644
--- a/tools/feed-momentum-test.js
+++ b/tools/feed-momentum-test.js
@@ -1,8 +1,7 @@
// Desktop feed scrolling feels like a phone: a mouse-wheel notch coasts
// farther than the browser's own step, quick notches stack, a reverse turn
// brakes; a trackpad's smooth deltas are left to the browser; a mouse drag
-// scrolls and a release while moving throws it; a drag that moved doesn't
-// click; a sideways drag is left alone (text selection).
+// selects text, as on any page.
// Uses a tall stand-in feed so no relays are needed.
// Run: bun tools/feed-momentum-test.js
import puppeteer from './puppeteer.js';
@@ -76,50 +75,17 @@ try {
check('...and a notch right after them too (still a trackpad)', !(await notch(1)));
await sleep(500);
- console.log('[grab and flick]');
+ console.log('[a mouse drag selects text]');
await top();
+ const b = await (await page.$('#tgt')).boundingBox();
+ await page.evaluate(() => { window.__clicks = 0; getSelection().removeAllRanges(); });
await page.mouse.move(300, 700); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 40); await sleep(8); }
- const during = await y();
await page.mouse.up();
- check('dragging up scrolls with the mouse', during >= 380 && during <= 420, during + 'px');
- const flung = await settle();
- check('letting go while moving throws it', flung > during + 400, flung + 'px');
- await top();
- await page.mouse.move(300, 700); await page.mouse.down();
- for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 30); await sleep(8); }
- await sleep(250); // hold still
- const held = await y();
- await page.mouse.up();
- check('held still before letting go: it stays put', (await settle()) - held < 5, held + ' → ' + await y());
- await top();
- const b = await (await page.$('#tgt')).boundingBox();
- await page.evaluate(() => { window.__clicks = 0; });
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 50, b.y + 150 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a drag that started on a button doesn\'t click it', await page.evaluate(() => window.__clicks) === 0);
- // a hand's flick often starts a little sideways
- await top();
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- await page.mouse.move(b.x + 58, b.y + 145);
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 60, b.y + 145 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a flick that starts a bit sideways still scrolls', (await settle()) > 50, await y() + 'px');
- check('...and doesn\'t click', await page.evaluate(() => window.__clicks) === 0);
- await top();
- await page.mouse.move(b.x + 20, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 8; i++) await page.mouse.move(b.x + 20 + i * 30, b.y + 152);
- await page.mouse.up(); await sleep(50);
- check('a sideways drag (selecting) doesn\'t click either', await page.evaluate(() => window.__clicks) === 0);
- await sleep(350);
+ check('dragging up doesn\'t scroll the feed', (await settle()) === 0, await y() + 'px');
+ check('...it selects text', await page.evaluate(() => getSelection().toString().length > 0));
await page.click('#tgt'); await sleep(50);
- check('...but a plain click still does', await page.evaluate(() => window.__clicks) === 1);
- await top();
- await page.mouse.move(100, 500); await page.mouse.down();
- for (let i = 1; i <= 10; i++) await page.mouse.move(100 + i * 30, 505);
- await page.mouse.up();
- check('a sideways drag doesn\'t scroll (it selects text)', (await settle()) === 0);
+ check('a plain click still clicks', await page.evaluate(() => window.__clicks) === 1);
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
console.log(ok ? 'PASS' : 'FAIL');
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
index ff69626..4cbe68c 100644
--- a/tools/feed-picture-pager-test.js
+++ b/tools/feed-picture-pager-test.js
@@ -36,6 +36,7 @@ const seeded = [
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
index 632bbc6..80e2fde 100644
--- a/tools/feed-wheel-reverse-test.js
+++ b/tools/feed-wheel-reverse-test.js
@@ -5,15 +5,18 @@ const source = await Bun.file('src/app.js').text();
const start = source.indexOf('(() => {', source.indexOf('// The feed scrolls like a phone'));
const end = source.indexOf("\nwindow.addEventListener('load'", start);
assert(start > 0 && end > start);
-function fixture() {
+function fixture(inside = '', { feedOnPage = true } = {}) {
let now = 100, id = 0;
const handlers = new Map(), frames = new Map(), steps = [];
const window = { scrollY: 5000,
addEventListener: (name, fn) => handlers.set(name, fn),
scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; },
};
- const document = { body: {}, addEventListener() {}, removeEventListener() {} };
- const target = { closest: (selector) => selector.includes('.feed-page') ? target : null,
+ // the page shows a feed (or, for a thread case, a thread) unless told otherwise
+ const document = { body: {}, addEventListener() {}, removeEventListener() {},
+ querySelector: (selector) => (feedOnPage && selector.split(/,\s*/).some((p) => p === (inside === '.thread-page' ? '.thread-page' : '.feed-page')) ? {} : null) };
+ // the target sits inside the given element (over the posts unless it names something else)
+ const target = { closest: (selector) => selector.split(/,\s*/).some((p) => p === inside) ? target : null,
scrollHeight: 100, clientHeight: 100, parentElement: document.body };
runInNewContext(source.slice(start, end), { window, document,
matchMedia: () => ({ matches: false }), performance: { now: () => now },
@@ -33,42 +36,61 @@ for (const dir of [1, -1]) {
const f = fixture();
f.wheel(dir); f.advance(17); f.frame();
f.wheel(dir); f.advance(17); f.frame();
- // A busy main thread has missed several frames when the reverse arrives.
+ // A busy main thread has missed several frames when the turn back arrives.
f.advance(120);
const before = f.y;
+ // [flying: the first turn back is a brake]
f.wheel(-dir);
- assert((f.y - before) * dir < 0, 'first reverse notch responds immediately');
- assert(Math.abs(f.y - before) < 20, 'reverse starts with a small bounded step');
- const reversed = f.y;
+ assert.equal(f.y, before, 'the brake notch moves nothing by itself');
f.frame(0); // a queued animation frame can carry an older timestamp
- assert.equal(f.y, reversed, 'old frame time cannot revive the previous direction');
- f.advance(17); f.frame();
- assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction');
- assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay');
+ assert.equal(f.y, before, 'old frame time moves nothing');
+ settle(f, 30);
+ const over = (f.y - before) * dir;
+ assert(over >= 0 && over < 120, 'the brake eases the flight out within a short way, never the other way: ' + over);
+ // [the next notch back: the gentle start]
+ const stopped = f.y;
+ f.wheel(-dir);
+ assert((stopped - f.y) * dir > 0 && (stopped - f.y) * dir < 20, 'the gentle start responds at once with a small step');
settle(f);
- // The turn back is a plain notch from rest: its full travel, no flight the other way.
- const back = (before - f.y) * dir;
- assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back);
- // For a moment after a reversal notches don't wind the speed up: a
- // reversal and a quick second notch travel about two notches, not a flight.
- let calm;
- { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way
- const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir;
- assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); }
- // From rest, well after, the wind-up is back: the same two quick notches go further.
- { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); }
- // A spin that keeps going after a reversal winds up again from its third
- // notch: a reversal plus four quick notches goes well past five plain ones.
- let spin;
- { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame();
- const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir;
- assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); }
- // ...and the same five quick notches from rest go about as far (the two calm ones cost little).
- { f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir;
- assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); }
+ const back = (stopped - f.y) * dir;
+ assert(back > 130 && back < 170, 'the notch after the brake creeps back half a notch: ' + back);
+ // [then the wind-up]
+ { const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f);
+ const on = (y - f.y) * dir; assert(on > 600, 'the notches after wind up: ' + on); }
+ // The gentle start waits a while for its notch: a second and a half after the brake, still gentle.
+ { f.advance(2500); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(120); f.frame();
+ f.wheel(-dir); settle(f, 30); f.advance(1500); const y = f.y; f.wheel(-dir); settle(f);
+ const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'a notch back a while after the brake is still the gentle start: ' + s); }
+ // ...but long after, the brake forgotten, a notch back is a plain push from rest.
+ { f.advance(2500); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(120); f.frame();
+ f.wheel(-dir); settle(f, 30); f.advance(3000); const y = f.y; f.wheel(-dir); settle(f);
+ const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'long after the brake, a notch back is a full push: ' + s); }
+ // A stroll (one notch) turned back has nothing worth braking: the gentle start at once.
+ { f.advance(2500); f.wheel(dir); f.advance(100); f.frame(); const y = f.y; f.wheel(-dir);
+ assert((y - f.y) * dir > 0 && (y - f.y) * dir < 20, 'against a stroll the turn back starts at once, with a small step');
+ settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'against a stroll the turn back is the gentle start: ' + s); }
// From rest a notch either way is a full push.
{ f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); }
- // A turn back against a coast that only just died is a reversal too (caught, no wind-up).
- { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); }
+ // A turn back against a coast that only just died is the gentle start too.
+ { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'reversing right after the coast dies is the gentle start: ' + s); }
+ // Well after the coast died it is a plain notch from rest, whichever way.
+ { f.advance(2500); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a notch the other way from rest is a full push: ' + s); }
+}
+// A notch over a video (or an audio player) in the feed coasts like any
+// other — the wheel does nothing to a player itself — and so does one on a
+// thread page, and one with the pointer anywhere else on the page while a
+// feed is up (the rail, the wallet column, the header).
+for (const sel of ['video', 'audio', '.thread-page', '.wallet-column', '']) {
+ const f = fixture(sel);
+ const y = f.y; f.wheel(1); settle(f);
+ assert(f.y - y > 280 && f.y - y < 330, 'a notch over ' + sel + ' coasts a full push: ' + (f.y - y));
+}
+// ...while a notch in a text box, or in the media pager, is left alone — and
+// so is every notch on a page with no feed on it (the wallet).
+for (const sel of ['textarea', '.media-pager']) {
+ const f = fixture(sel);
+ const y = f.y; f.wheel(1); settle(f);
+ assert.equal(f.y, y, 'a notch in ' + sel + ' is not ours');
}
-console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back');
+{ const f = fixture('', { feedOnPage: false }); const y = f.y; f.wheel(1); settle(f); assert.equal(f.y, y, 'no feed on the page: the wheel is the browser\'s'); }
+console.log('✓ a turn back against a flight brakes, the next notch is the gentle start, then the wind-up; against a stroll or a dead coast the gentle start at once; stale frames; videos and threads coast');
diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js
new file mode 100644
index 0000000..0a3feea
--- /dev/null
+++ b/tools/hls-upload-test.js
@@ -0,0 +1,92 @@
+// A long video posted from the app goes up as an HLS stream: made on the
+// device (two renditions, a file each, read in ranges), put on one Blossom
+// host with the playlists naming the files' addresses, and the master
+// playlist's address is what the post carries. Chrome plays it from there
+// and seeks in it. A short clip is no stream.
+// Run: bun tools/hls-upload-test.js
+import puppeteer from './puppeteer.js';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/hls-upload-');
+const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]);
+make(95, dir + '/long.mp4');
+make(20, dir + '/short.mp4');
+const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true });
+if (!built.success) throw new Error(built.logs.join('\n'));
+const js = new Map();
+for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text());
+// a Blossom host: PUT /upload keeps the body under its hash; GET /<hash>.<ext> serves it in ranges
+const blobs = new Map(), types = new Map();
+const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
+ const u = new URL(req.url);
+ if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } });
+ if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname));
+ if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 });
+ if (u.pathname === '/upload' && req.method === 'PUT') {
+ const body = new Uint8Array(await req.arrayBuffer());
+ const x = createHash('sha256').update(body).digest('hex');
+ blobs.set(x, body); types.set(x, req.headers.get('content-type'));
+ return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length });
+ }
+ const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname);
+ if (m && blobs.has(m[1])) {
+ const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' };
+ if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); }
+ return new Response(buf, { headers });
+ }
+ return new Response('<!doctype html><video muted></video>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const run = (name) => page.evaluate(async (name) => {
+ const vc = await import('/video-compress.js'), mu = await import('/media-upload.js');
+ const blob = await (await fetch('/' + name)).blob();
+ const file = new File([blob], name, { type: 'video/mp4' });
+ const t0 = performance.now();
+ const stream = await vc.streamVideo(file);
+ if (!stream) return { stream: null };
+ const made = Math.round(performance.now() - t0);
+ let progress = 0;
+ const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } });
+ return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds };
+ }, name);
+ const short = await run('short.mp4');
+ check('a 20 s clip is not made a stream', short.stream === null);
+ const long = await run('long.mp4');
+ check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms');
+ check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url);
+ check('...the upload reported its progress to the end', long.progress === 1, String(long.progress));
+ check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds);
+ const master = await (await fetch(long.url)).text();
+ const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : []));
+ check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' '));
+ check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master));
+ const media = await (await fetch(lists.at(-1))).text();
+ check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media));
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message };
+ v.currentTime = 70;
+ const t1 = performance.now();
+ while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, long.url);
+ check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first));
+ check('...and seeks in it', played.after >= 71, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ a long video goes up as an HLS stream' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/negentropy-sync-test.js b/tools/negentropy-sync-test.js
new file mode 100644
index 0000000..e0257ca
--- /dev/null
+++ b/tools/negentropy-sync-test.js
@@ -0,0 +1,159 @@
+// Catch-up by reconciliation (NIP-77). A relay that speaks negentropy —
+// relay.coinos.io does — is no longer asked to re-serve its newest events:
+// the DM subscription opens live with limit 0 and the wraps we lack are
+// fetched by id after a reconcile; the Notifications refresh reconciles
+// its newest 120 against what we hold and fetches only the new ones, and
+// a refresh holding everything fetches nothing. A relay that answers
+// "negentropy disabled" (nos.lol) gets the plain subscription with its
+// backlog, and is remembered as such for a day: the next ask there goes
+// straight out as a REQ.
+// Run: bun tools/negentropy-sync-test.js
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const FRIEND = key(), JOHNNY = key(), SENDER = key();
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+// the client library plays the relay's side in the page
+const negSrc = (await Bun.file('src/negentropy.js').text()).replace(/^export /gm, '') + '\nreturn { NegSet, Negentropy };';
+const server = Bun.serve({ port: 5337, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js' || path.startsWith('/locales')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
+const sent = () => page.evaluate(() => window.__sent);
+const rows = () => page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => r.innerText.replace(/\s+/g, ' ').trim()));
+const host = (u) => u.replace(/^wss:\/\//, '').replace(/\/$/, '');
+const shape = (m) => {
+ const f = m[0] === 'REQ' ? m[2] : m[0] === 'NEG-OPEN' ? m[2] : null;
+ return m[0] + (f ? ' kinds=' + (f.kinds || []).join(',') + (f.ids ? ' ids=' + f.ids.length : '') + ('limit' in f ? ' limit=' + f.limit : '') + (f.since ? ' since=' + (f.since - Math.floor(Date.now() / 1000)) : '') : '');
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5337/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const ME = getPublicKey(privateKeyFromSeedWords(mn));
+ const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND.sk);
+ const reply = (who, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['e', MINE.id, '', 'root'], ['p', ME, '', 'mention']], content }, who.sk);
+ const NOTIFS = [reply(JOHNNY, 'johnny says hello there', 100), finalizeEvent({ kind: 7, created_at: now - 90, tags: [['e', MINE.id], ['p', ME]], content: '+' }, JOHNNY.sk), reply(FRIEND, 'a reply from a friend', 70)];
+ // gift wraps to me (sealed to nobody: they will not open, which is beside the point here)
+ const WRAPS = [1, 2, 3].map((i) => finalizeEvent({ kind: 1059, created_at: now - i * 3600, tags: [['p', ME]], content: 'wrapped ' + i }, SENDER.sk));
+ const PROFILES = [finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name: 'Johnny' }) }, JOHNNY.sk), finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name: 'Friend' }) }, FRIEND.sk)];
+ await page.evaluateOnNewDocument((fx, negSrc) => {
+ const { NegSet, Negentropy } = new Function(negSrc)();
+ window.__sent = [];
+ const Real = window.WebSocket;
+ const say = (ws, m) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(m) })); };
+ const store = [...fx.notifs, ...fx.wraps, fx.mine, ...fx.profiles];
+ const matches = (f) => store.filter((e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
+ && (!f['#p'] || e.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1]))) && (!f['#e'] || e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))));
+ const newest = (evs, f) => { const s = [...evs].sort((a, b) => b.created_at - a.created_at); return 'limit' in f ? s.slice(0, f.limit) : s; };
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const url = String(a[0]);
+ const coinos = /relay\.coinos\.io/.test(url);
+ const negs = new Map(); // subid -> relay side
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ window.__sent.push({ url, m });
+ if (m[0] === 'REQ') {
+ const hit = newest(matches(m[2]), m[2]);
+ setTimeout(() => { for (const e of hit) say(ws, ['EVENT', m[1], e]); say(ws, ['EOSE', m[1]]); }, 30);
+ return;
+ }
+ if (m[0] === 'NEG-OPEN') {
+ if (!coinos) { setTimeout(() => say(ws, ['NOTICE', 'ERROR: bad msg: negentropy disabled']), 30); return; }
+ const items = newest(matches(m[2]), m[2]).map((e) => [e.created_at, e.id]);
+ const n = new Negentropy(new NegSet(items));
+ negs.set(m[1], n);
+ n.reconcile(m[3]).then((r) => say(ws, ['NEG-MSG', m[1], r.next]));
+ return;
+ }
+ if (m[0] === 'NEG-MSG') { const n = negs.get(m[1]); if (n) n.reconcile(m[2]).then((r) => say(ws, ['NEG-MSG', m[1], r.next])); return; }
+ if (m[0] === 'NEG-CLOSE') { negs.delete(m[1]); return; }
+ if (m[0] === 'EVENT') { setTimeout(() => say(ws, ['OK', m[1].id, true, '']), 0); return; }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ }, { me: ME, mine: MINE, notifs: NOTIFS, wraps: WRAPS, profiles: PROFILES }, negSrc);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+
+ console.log('\n[the DM subscription]');
+ await waitFor(() => window.__sent.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length), null, 15000);
+ await sleep(500);
+ let log = await sent();
+ const at = (re) => log.filter((x) => re.test(x.url)).map((x) => shape(x.m));
+ const coinosDm = at(/coinos/).filter((s) => /kinds=1059/.test(s));
+ check('relay.coinos.io: the live wrap subscription opens with limit 0', coinosDm.some((s) => s === 'REQ kinds=1059 limit=0'), coinosDm.join(' | '));
+ check('…and a reconcile of the newest 400', coinosDm.some((s) => s === 'NEG-OPEN kinds=1059 limit=400'));
+ check('…the relay is never asked to re-serve its backlog', !coinosDm.some((s) => /^REQ kinds=1059 limit=400/.test(s)));
+ const idsAsk = log.find((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length);
+ check('…and the wraps we lack are fetched by id', !!idsAsk && WRAPS.every((w) => idsAsk.m[2].ids.includes(w.id)), idsAsk ? idsAsk.m[2].ids.length + ' ids' : 'no ask by id');
+ const nosDm = at(/nos\.lol/).filter((s) => /kinds=1059/.test(s));
+ check('nos.lol says negentropy is disabled: it gets the plain subscription, backlog and all', nosDm.includes('NEG-OPEN kinds=1059 limit=400') && nosDm.includes('REQ kinds=1059 limit=400'), nosDm.join(' | '));
+ check('…and is remembered as such', await page.evaluate(() => /nos\.lol/.test(localStorage.getItem('coinos-neg-off') || '')));
+
+ console.log('\n[Notifications]');
+ const before = log.length;
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click());
+ let got = [];
+ for (let i = 0; i < 60; i++) { got = await rows(); if (got.length >= 3) break; await sleep(250); }
+ check('the three notifications show', got.length === 3, got.join(' | '));
+ log = (await sent()).slice(before);
+ const coinosN = log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m)).filter((s) => /kinds=1,/.test(s) || /ids=/.test(s));
+ console.log(' coinos:', log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m) + (x.m[0] === 'REQ' && x.m[2].ids ? ':' + x.m[2].ids.map((i) => i.slice(0, 6)).join(',') : '') + (x.m[0] === 'NEG-MSG' ? ' ' + x.m[2].length + 'ch' : '')).join(' | '));
+ console.log(' notif ids:', NOTIFS.map((n) => n.id.slice(0, 6)).join(','), 'mine', MINE.id.slice(0, 6));
+ check('relay.coinos.io: reconciled, its newest 120', coinosN.some((s) => /^NEG-OPEN kinds=1,1111,6,7.* limit=120$/.test(s)), coinosN.join(' | '));
+ check('…not asked for them outright', !coinosN.some((s) => /^REQ kinds=1,1111,6,7.* limit=120$/.test(s)));
+ // (the first refresh ran at boot, for the unread count: that is the one that fetched them)
+ const all = await sent();
+ const nAsk = all.find((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length && NOTIFS.some((n) => x.m[2].ids.includes(n.id)));
+ check('…the three we lacked fetched by id, once, at the boot refresh', !!nAsk && nAsk.m[2].ids.length === 3 && all.indexOf(nAsk) < before, nAsk ? nAsk.m[2].ids.length + ' ids at ' + all.indexOf(nAsk) + ' (page opened at ' + before + ')' : 'none');
+ check('…and the page\'s own refresh, holding them, fetched nothing', !log.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).some((id) => NOTIFS.some((n) => n.id === id))));
+ const nosN = log.filter((x) => /nos\.lol/.test(x.url)).map((x) => shape(x.m));
+ check('nos.lol: the plain query, straight out, no reconcile tried', nosN.some((s) => /^REQ kinds=1,1111,6,7.* limit=120$/.test(s)) && !nosN.some((s) => s.startsWith('NEG-OPEN')), nosN.join(' | '));
+
+ console.log('\n[a refresh holding everything, after a reload]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ const b2 = (await sent()).length;
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click());
+ for (let i = 0; i < 60; i++) { got = await rows(); if (got.length >= 3) break; await sleep(250); }
+ check('the notifications are there from the cache', got.length === 3, got.join(' | '));
+ await waitFor((n) => window.__sent.slice(n).some((x) => /coinos/.test(x.url) && x.m[0] === 'NEG-CLOSE'), b2, 10000);
+ await sleep(800);
+ log = (await sent()).slice(b2);
+ const again = log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m)).filter((s) => /kinds=1,/.test(s) || /ids=/.test(s));
+ check('relay.coinos.io reconciled again…', again.some((s) => /^NEG-OPEN kinds=1,1111,6,7.* limit=120$/.test(s)), again.join(' | '));
+ check('…and nothing was fetched: we held it all', !log.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).some((id) => NOTIFS.some((n) => n.id === id))));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ catch-up by reconciliation where the relay speaks it, the plain query where it does not' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/negentropy-test.js b/tools/negentropy-test.js
new file mode 100644
index 0000000..28a1f30
--- /dev/null
+++ b/tools/negentropy-test.js
@@ -0,0 +1,64 @@
+// The negentropy client (NIP-77, src/negentropy.js) on the wire as strfry
+// and the reference implementation speak it: the opening message for a
+// fixed set is byte-identical to the reference's (checked against
+// hoytech/negentropy's JS on 2026-10-16: seven pairings of ours and theirs
+// as client and relay agreed on every have/need), and a reconciliation
+// against our own relay side finds exactly what each side lacks — in one
+// round for a small difference, in several when the relay's frame is
+// small — and ends with nothing to say once the sets match.
+// Run: bun tools/negentropy-test.js
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { NegSet, Negentropy } from '../src/negentropy.js';
+const id = (n) => createHash('sha256').update('item-' + n).digest('hex');
+const sha = (s) => createHash('sha256').update(s).digest('hex');
+
+// the fixed sets the reference was run on
+const A = Array.from({ length: 120 }, (_, i) => [1000 + Math.floor(i / 3), id(i)]);
+const B = [...A.filter((_, i) => i % 17 !== 0), ...Array.from({ length: 9 }, (_, i) => [1000 + i * 4, id(1000 + i)])];
+const first = await new Negentropy(new NegSet(A)).initiate();
+assert.equal(first.length, 634, 'the opening message for the fixed set is 317 bytes');
+assert.equal(first.slice(0, 2), '61', 'protocol version 1');
+assert.equal(sha(first), 'c926c013652f6e2f727abff15c0838aebd7f613fb4749ee691952dfc8be8ff81', 'the opening message is the reference implementation\'s, byte for byte');
+
+async function talk(client, relay) {
+ let msg = await client.initiate(), rounds = 0;
+ const have = [], need = [];
+ while (msg) {
+ rounds++;
+ const r = await relay.reconcile(msg);
+ assert(r.next, 'the relay side always answers');
+ const c = await client.reconcile(r.next);
+ have.push(...c.have); need.push(...c.need); msg = c.next;
+ }
+ return { rounds, have: have.sort(), need: need.sort() };
+}
+{
+ const r = await talk(new Negentropy(new NegSet(A)), new Negentropy(new NegSet(B)));
+ assert.equal(r.rounds, 1, 'a small difference reconciles in one round');
+ assert.deepEqual(r.have, A.filter((_, i) => i % 17 === 0).map((x) => x[1]).sort(), 'have: the ids the relay lacks');
+ assert.deepEqual(r.need, Array.from({ length: 9 }, (_, i) => id(1000 + i)).sort(), 'need: the ids we lack');
+}
+{
+ const r = await talk(new Negentropy(new NegSet(A)), new Negentropy(new NegSet(A)));
+ assert.equal(r.rounds, 1); assert.deepEqual(r.have, []); assert.deepEqual(r.need, []);
+ const r2 = await talk(new Negentropy(new NegSet([])), new Negentropy(new NegSet(B)));
+ assert.equal(r2.need.length, B.length, 'holding nothing, we need everything');
+ const r3 = await talk(new Negentropy(new NegSet(B)), new Negentropy(new NegSet([])));
+ assert.equal(r3.have.length, B.length, 'the relay holding nothing lacks everything');
+}
+{
+ // a big set and a relay with a small frame: many rounds, the same answer
+ const big = Array.from({ length: 3000 }, (_, i) => [1000 + i, id(i)]);
+ const theirs = [...big.filter((_, i) => i % 101 !== 0), ...Array.from({ length: 25 }, (_, i) => [1000 + i * 7, id(5000 + i)])];
+ const r = await talk(new Negentropy(new NegSet(big)), new Negentropy(new NegSet(theirs), { frameSizeLimit: 4096 }));
+ assert(r.rounds > 1, 'a small frame takes several rounds: ' + r.rounds);
+ assert.equal(r.have.length, 30); assert.equal(r.need.length, 25);
+}
+{
+ // the set drops duplicates and bad ids, and sorts by time then id
+ const s = new NegSet([[5, id(1)], [5, id(1)], [3, 'nope'], [4, id(2)], [5, id(0)]]);
+ assert.equal(s.size, 3);
+ assert.deepEqual(s.items.map((x) => x.ts), [4, 5, 5]);
+}
+console.log('✓ negentropy: the reference\'s bytes, one round for a small difference, several for a small frame, nothing to say when equal');
diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js
new file mode 100644
index 0000000..5952d81
--- /dev/null
+++ b/tools/pager-caption-test.js
@@ -0,0 +1,134 @@
+// The video pager's caption is the post's words with their names, tags and
+// links live: a tap on @someone leaves the pager and opens their profile
+// (a plain excerpt left "@NostrValley" dead); a tap anywhere else on the
+// caption leaves the pager and opens the post, where the whole caption
+// reads. A link to the web opens in its own tab and the pager stays.
+// Run: bun tools/pager-caption-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), VALLEY = getPublicKey(generateSecretKey());
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|tall)\.webm$/.exec(path);
+ if (m) return new Response(Bun.file(CLIPS[m[1]]), { headers: { 'content-type': 'video/webm' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const seeded = [
+ post('Opening night nostr:' + npubEncode(VALLEY) + ' downtown entertainment, a long night of it with music and more ' + U('wide'), 100),
+ post('a tall one https://example.com/about ' + U('tall'), 200),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4), 600 + i * 60)),
+];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ window.__opened = [];
+ window.open = (u) => { window.__opened.push(String(u)); return null; };
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const openClip = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
+ await sleep(400);
+ const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ await page.mouse.click(at.x, at.y);
+ const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000);
+ // the caption and the counts show on a tap on the clip (details), as a reader sees them
+ await sleep(500);
+ await page.mouse.click(195, 300);
+ await waitFor(() => !!document.querySelector('.media-pager.show'), null, 3000);
+ return opened;
+};
+// the caption of the slide on screen
+const CAP = () => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => Math.abs(x.getBoundingClientRect().top) < 4); return s && s.querySelector('.mp-caption'); };
+const caption = () => page.evaluate((cap) => { const c = eval(cap)(); return c ? { text: c.innerText, links: [...c.querySelectorAll('a')].map((a) => a.textContent) } : null; }, '(' + CAP + ')');
+const clickIn = async (sel, where) => {
+ const at = await page.evaluate(([cap, sel, where]) => {
+ const c = eval(cap)(); const el = sel ? c.querySelector(sel) : c; const r = el.getBoundingClientRect();
+ // the caption's first word sits at its left edge; a link in the middle
+ return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ }, ['(' + CAP + ')', sel, where]);
+ await page.mouse.click(at.x, at.y);
+};
+const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, valley, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'OpenMike', t: Date.now() }, [valley]: { name: 'NostrValley', about: 'the valley bio here', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, VALLEY, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await toFeed();
+
+ console.log('\n[a name in the caption]');
+ check('the pager opens on the clip', await openClip('Opening night'));
+ await sleep(500);
+ let c = await caption();
+ check('the caption shows the post\'s words with the name in them', !!c && /Opening night @NostrValley downtown/.test(c.text), JSON.stringify(c));
+ check('…and the name is a link', !!c && c.links.includes('@NostrValley'), JSON.stringify(c));
+ await clickIn('a', 'middle');
+ check('a tap on the name leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…out of full screen', await page.evaluate(() => !document.fullscreenElement));
+ check('…on their profile', await waitText('the valley bio here', 6000));
+
+ console.log('\n[the caption\'s own words]');
+ await toFeed();
+ check('the pager opens again', await openClip('Opening night'));
+ await sleep(500);
+ await clickIn(null, 'start');
+ check('a tap on the words leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…and opens the post', await waitFor(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a long night of it with music and more'), null, 6000));
+
+ console.log('\n[a link to the web]');
+ await toFeed();
+ check('the pager opens on the other clip', await openClip('a tall one'));
+ await sleep(500);
+ c = await caption();
+ check('its link is a link', !!c && c.links.some((l) => /example\.com/.test(l)), JSON.stringify(c));
+ const newTab = new Promise((r) => browser.once('targetcreated', (t) => r(t.url())));
+ await clickIn('a[href^="http"]', 'middle');
+ const tab = await Promise.race([newTab, sleep(3000).then(() => null)]);
+ check('a tap on it opens the page in its own tab', !!tab && /example\.com\/about/.test(tab), String(tab));
+ check('…and the pager stays open', await page.evaluate(() => !!document.querySelector('.media-pager') && location.pathname !== '/about'));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the pager\'s caption: names open profiles, words open the post, web links stay' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js
new file mode 100644
index 0000000..6507607
--- /dev/null
+++ b/tools/pager-first-frame-test.js
@@ -0,0 +1,124 @@
+// The video pager's slides near the reader hold their clip's first frame
+// before the reader gets there: the server's (vmedia first.jpg — frame 0 at
+// full size, any host), put on the slide once fetched and decoded; where the
+// server has none, the next clip's frame is drawn here from its cached
+// opening, and a clip beyond gets no picture (and stays out of the
+// carousel) until a first frame comes — never the post's still first. The slide left behind keeps its frame for the swipe
+// back. Run: bun tools/pager-first-frame-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const jpg = (c, size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=' + c + ':s=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+const FIRST = { wide: jpg('orange', '1280x720'), tall: jpg('green', '720x1280') }; // (last: the server has none)
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return Buffer.from(Bun.spawnSync(['cat', f]).stdout);
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => 'https://media.test/' + n + '.webm';
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = [];
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.pathname === '/vmedia/first.jpg') {
+ const name = /\/(\w+)\.webm$/.exec(u.searchParams.get('u') || '')?.[1];
+ asked.push('first:' + name);
+ if (!FIRST[name]) return r.respond({ status: 404, body: 'No picture', headers: { 'access-control-allow-origin': '*' } });
+ return r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } });
+ }
+ if (u.pathname.startsWith('/vmedia/')) { asked.push('vmedia:' + u.pathname); return r.respond({ status: 404, body: 'no' }); }
+ if (u.host === 'media.test') {
+ const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[name];
+ const range = /^bytes=(\d+)-(\d*)$/.exec(r.headers().range || '');
+ const cors = { 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'access-control-expose-headers': 'content-range, content-length' };
+ if (!range) return r.respond({ status: 200, contentType: 'video/webm', body: f, headers: cors });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.length - 1) : f.length - 1;
+ return r.respond({ status: 206, contentType: 'video/webm', body: f.subarray(a, b + 1), headers: { ...cors, 'content-range': 'bytes ' + a + '-' + b + '/' + f.length } });
+ }
+ r.continue();
+});
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const kind = (p) => (!p ? 'none' : /\/vmedia\/first\.jpg/.test(p) ? 'server' : /^blob:/.test(p) ? 'drawn' : /still\.png/.test(p) ? 'still' : p);
+const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), poster: v.getAttribute('poster'), playing: !v.paused, here: Math.abs(r.top) < 4 }; }))
+ .then((l) => l.map((x) => ({ ...x, poster: kind(x.poster) })));
+const posterOf = async (clip) => (await slides()).find((x) => x.clip === clip)?.poster;
+const waitPoster = (clip, k, ms = 8000) => waitFor(([c, k]) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + c + '.webm')); const p = s && s.querySelector('video.mp-media').getAttribute('poster'); return !!p && (k === 'server' ? /\/vmedia\/first\.jpg/.test(p) : k === 'drawn' ? /^blob:/.test(p) : /still\.png/.test(p)); }, [clip, k], ms);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
+ await sleep(600);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
+ check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
+
+ console.log('\n[the slides ahead hold their first frames]');
+ check('the next clip holds the server\'s first frame', await waitPoster('tall', 'server'), JSON.stringify(await slides()));
+ check('…the server was asked for it once', asked.filter((x) => x === 'first:tall').length === 1, asked.join(' '));
+ await sleep(1500);
+ check('the clip after, which the server has no frame for, gets no stand-in still', (await posterOf('last')) === 'none', JSON.stringify(await slides()));
+ check('…and waits out of the carousel', await page.evaluate(() => [...document.querySelectorAll('.mp-group')].find((g) => /last\.webm$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))).classList.contains('mp-wait')));
+ check('…no thumbnail route was tried for any of them', !asked.some((x) => /thumb\.jpg/.test(x)), asked.join(' '));
+ await page.keyboard.press('ArrowDown');
+ check('on to the next: it plays', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm') && !s.querySelector('video').paused; }, null, 6000), JSON.stringify(await slides()));
+ check('…the server\'s frame still under it', (await posterOf('tall')) === 'server', String(await posterOf('tall')));
+ check('the one left behind holds its first frame for the swipe back', await waitPoster('wide', 'server'), JSON.stringify(await slides()));
+ check('the next, now the clip ahead, has its frame drawn here ', await waitPoster('last', 'drawn', 12000), JSON.stringify(await slides()));
+ check('…the server asked only once more for it, not again and again', asked.filter((x) => x === 'first:last').length === 1, asked.join(' '));
+ await page.keyboard.press('ArrowUp');
+ check('back on the first: it plays again, its frame under it', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/wide.webm') && !s.querySelector('video').paused && /first\.jpg/.test(s.querySelector('video').getAttribute('poster') || ''); }, null, 6000), JSON.stringify(await slides()));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the slides near the reader hold their clips\' first frames' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-gone-picture-test.js b/tools/pager-gone-picture-test.js
index 683493b..34c89b6 100644
--- a/tools/pager-gone-picture-test.js
+++ b/tools/pager-gone-picture-test.js
@@ -1,8 +1,7 @@
// A picture whose host is down (its server answers 502, so neither a copy
-// nor the original can load) shows a plain "couldn't be loaded" slide with
-// the host's name, not the browser's broken-picture glyph in a corner
-// (Adam's screenshot, 2026-10-16: isolabellart's relay down). The slides
-// around it are untouched. Run: bun tools/pager-gone-picture-test.js
+// nor the original can load) never joins the pager's carousel: a picture
+// joins once decoded (2026-10-17), and a step passes over it to the next.
+// (Its slide is marked gone, with the host's name, as before — out of sight.) Run: bun tools/pager-gone-picture-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
@@ -30,6 +29,7 @@ const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big
...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60))];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -63,15 +63,10 @@ try {
await page.mouse.click(at.x, at.y);
check('the picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide img.mp-media'), null, 4000));
check('the picture that loads is sharpened at rest', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big1.png'); }, null, 5000));
+ check('the dead picture\'s post never joins the carousel: both its copy and its original were tried', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); const g = s && s.closest('.mp-group'); return !!g && g.classList.contains('mp-wait') && s.classList.contains('mp-gone') && s.querySelector('img.mp-media').getAttribute('src').endsWith('/big2.png'); }, null, 8000));
await page.keyboard.press('ArrowDown');
- check('down: the dead picture\'s slide', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000));
- check('it says the picture couldn\'t be loaded, naming the host', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c.classList.contains('mp-gone') && /couldn’t be loaded/.test(c.textContent) && /localhost/.test(c.querySelector('.mp-missing')?.textContent || ''); }, null, 6000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur').outerHTML.slice(0, 300)));
- check('…and the broken <img> is hidden', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur img.mp-media')).display === 'none'));
- check('the original was tried after the copy failed', await page.evaluate(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big2.png')));
- await page.keyboard.press('ArrowDown');
- check('the next slide is fine', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000));
- check('its caption and controls are still there on the dead one', await page.evaluate(() => { const g = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); return !!g.querySelector('.mp-like') && /second big/.test(g.textContent); }));
+ check('down: past it, to the next picture', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur')?.getAttribute('data-k')));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
-console.log(ok ? '\n✅ a dead picture says so' : '\n❌ failed');
+console.log(ok ? '\n✅ a dead picture is passed over' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js
index 76e2764..42a5344 100644
--- a/tools/pager-loading-test.js
+++ b/tools/pager-loading-test.js
@@ -21,7 +21,7 @@ function fixture(kind = 'images', grid = false) {
};
const src = { c: cache, more, done: () => false };
const ctx = vm.createContext({
- pager: p, pagerGroups: () => groups.slice(0, count),
+ pager: p, pagerGroups: () => groups.slice(0, count), pagerFound: () => groups.slice(0, count),
pagerSource: () => grid ? null : src, feedNow: () => cache, mediaIndex: () => index, feedDef: () => ({}),
crawlMedia: (c, k) => { assert.equal(c, cache); assert.equal(k, kind); return more(); },
growPager: () => {}, Date: { now: () => clock },
diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js
new file mode 100644
index 0000000..0930834
--- /dev/null
+++ b/tools/pager-no-poster-test.js
@@ -0,0 +1,123 @@
+// A clip that plays by itself in the video pager has no thumbnail poster:
+// every poster there was came 480px wide and from some moment of the
+// clip, so it showed soft for a beat and the clip's first frame replaced
+// it — a flash on every flick. What the clip ahead holds instead is its
+// own first frame, full size, drawn from the opening of the file: the
+// flick glides onto the very picture the player shows first. On a
+// connection to be careful with (Data Saver), where a clip may never load,
+// the thumbnail poster stays.
+// Run: bun tools/pager-no-poster-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|tall|last)\.webm$/.exec(path);
+ if (m) {
+ // a host that serves ranges, as the real ones do
+ const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
+ if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1;
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } });
+ }
+ if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+// each clip's post names a poster for it (imeta), the way uploaders do
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400),
+ ...Array.from({ length: 6 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 600 - i * 60, tags: [], content: 'filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4) }, SK))];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ window.__conn = { saveData: localStorage.getItem('__saveData') === '1' }; // (survives the reloads)
+ Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true });
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop(), poster: v.getAttribute('poster'), src: !!v.getAttribute('src'), playing: !v.paused, here: Math.abs(r.top) < 4 }; }));
+const openClip = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
+ await sleep(400);
+ const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ const open = async () => {
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+ await openClip('a wide one');
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
+ await waitFor(() => [...document.querySelectorAll('.mp-slide video.mp-media')].some((v) => !v.paused), null, 6000);
+ await sleep(600);
+ return slides();
+ };
+
+ console.log('\n[playing by itself]');
+ let s = await open();
+ check('the clip on screen plays', s.some((x) => x.here && x.playing), JSON.stringify(s));
+ check('…with no poster on it', s.filter((x) => x.here).every((x) => !x.poster), JSON.stringify(s.filter((x) => x.here)));
+ check('…no thumbnail on the clips ahead either', s.filter((x) => !x.here).every((x) => !/still\.png/.test(x.poster || '')), JSON.stringify(s.filter((x) => !x.here)));
+ check('the next clip holds its own first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000), JSON.stringify(await slides()));
+ const frame = await page.evaluate(() => new Promise((r) => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')).querySelector('video.mp-media'); const im = new Image(); im.onload = () => r({ w: im.naturalWidth, h: im.naturalHeight }); im.onerror = () => r(null); im.src = v.getAttribute('poster'); }));
+ check('…full size, the clip\'s own shape', !!frame && frame.w === 180 && frame.h === 320, JSON.stringify(frame));
+ check('…and the clip after it, not yet near, holds nothing', s.some((x) => x.clip === 'last.webm' && !x.poster), JSON.stringify(s));
+ await page.keyboard.press('ArrowDown'); await sleep(900);
+ s = await slides();
+ check('the next clip plays, its first frame under it', s.some((x) => x.here && x.playing && /^blob:/.test(x.poster || '') && x.clip === 'tall.webm'), JSON.stringify(s));
+ check('…and the one after now holds its first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/last.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000));
+ await page.keyboard.press('Escape'); await sleep(500);
+
+ console.log('\n[a careful connection: the poster stays]');
+ await page.evaluate(() => { localStorage.setItem('__saveData', '1'); });
+ s = await open();
+ check('the slides carry their posters', s.length >= 3 && s.every((x) => /still\.png$/.test(x.poster || '')), JSON.stringify(s));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ an autoplaying clip starts on its own first frame, the one ahead holds it already' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-ready-gate-test.js b/tools/pager-ready-gate-test.js
new file mode 100644
index 0000000..dd6982d
--- /dev/null
+++ b/tools/pager-ready-gate-test.js
@@ -0,0 +1,153 @@
+// The video pager never lands on a black slide: a post joins the carousel
+// only once its clip is ready to play (its first frame on the slide, its
+// opening in the cache), the one tapped excepted; a post that joins above
+// the clip on screen doesn't move it. And the carousel — like the Videos
+// grid — shows the clips the screen's way round (tall ones on a phone held
+// upright), a square either way, until the toggle asks for all shapes.
+// Run: bun tools/pager-ready-gate-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const jpg = (c, size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=' + c + ':s=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return Buffer.from(Bun.spawnSync(['cat', f]).stdout);
+};
+// name → [shape, dims, first-frame delay ms]
+const SHAPES = { tall1: ['180x320', 0], wide: ['320x180', 0], tall2: ['180x320', 3000], tall3: ['180x320', 0], sq: ['240x240', 0] };
+const CLIPS = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, clip(size === '180x320' ? 'tall' : size === '320x180' ? 'wide' : 'last', size)]));
+const FIRST = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, jpg('green', size.split('x').map((x) => x * 4).join('x'))]));
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => 'https://media.test/' + n + '.webm';
+const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'dim ' + SHAPES[n][0]]], content: 'clip ' + n + ' ' + U(n) }, SK);
+const seeded = ['tall1', 'wide', 'tall2', 'tall3', 'sq'].map((n, i) => post(n, 100 + i * 100));
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = [];
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.pathname === '/vmedia/first.jpg') {
+ const name = /\/(\w+)\.webm$/.exec(u.searchParams.get('u') || '')?.[1];
+ asked.push('first:' + name);
+ const go = () => r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } });
+ return SHAPES[name][1] ? void setTimeout(go, SHAPES[name][1]) : go();
+ }
+ if (u.pathname.startsWith('/vmedia/')) return r.respond({ status: 404, body: 'no' });
+ if (u.host === 'media.test') {
+ const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[name];
+ asked.push('clip:' + name);
+ const range = /^bytes=(\d+)-(\d*)$/.exec(r.headers().range || '');
+ const cors = { 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'access-control-expose-headers': 'content-range, content-length' };
+ if (!range) return r.respond({ status: 200, contentType: 'video/webm', body: f, headers: cors });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.length - 1) : f.length - 1;
+ return r.respond({ status: 206, contentType: 'video/webm', body: f.subarray(a, b + 1), headers: { ...cors, 'content-range': 'bytes ' + a + '-' + b + '/' + f.length } });
+ }
+ r.continue();
+});
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const groups = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group')].map((g) => { const s = g.querySelector('.mp-slide'), v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), wait: g.classList.contains('mp-wait'), off: g.classList.contains('mp-off'), cur: s.classList.contains('mp-cur'), top: Math.round(r.top), poster: !!v.getAttribute('poster'), playing: !v.paused }; }));
+const curClip = async () => (await groups()).find((g) => g.cur)?.clip;
+const inCarousel = async () => (await groups()).filter((g) => !g.wait && !g.off).map((g) => g.clip).join(' ');
+const counter = () => page.evaluate(() => document.querySelector('.mp-position').textContent);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+
+ console.log('\n[the Videos grid: the clips the screen\'s way round]');
+ await click('videos');
+ check('the grid shows the tall clips and the square one, not the wide one', await waitFor(() => { const t = [...document.querySelectorAll('.media-tile.video')].map((x) => x.getAttribute('data-key')); return t.length === 4 && !t.some((k) => /wide/.test(k)); }, null, 8000), String(await page.evaluate(() => document.querySelectorAll('.media-tile.video').length)));
+ check('…with no toggle over it (that lives in the pager)', await page.evaluate(() => !document.querySelector('.media-fit-btn, .media-grid .mp-fit')));
+
+ console.log('\n[the carousel: a post joins once its clip is ready to play]');
+ await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].find((x) => /tall1/.test(x.getAttribute('data-key'))).click());
+ check('the pager opens on the clip tapped', await waitFor(() => { const s = document.querySelector('.media-pager.videos .mp-slide.mp-cur'); return !!s && /tall1/.test(s.getAttribute('data-k')); }, null, 6000));
+ check('…and it plays', await waitFor(() => !document.querySelector('.mp-slide.mp-cur video').paused, null, 8000));
+ check('the wide clip is not in this carousel at all (the grid\'s list is the screen\'s way round)', !(await groups()).some((g) => g.clip === 'wide'), JSON.stringify(await groups()));
+ check('the next tall clip, its first frame still on its way, waits out of the carousel', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-wait'); }, null, 1000), await inCarousel());
+ check('the one after, ready, has joined', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall3/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait') && !!g.querySelector('video').getAttribute('poster'); }, null, 8000), await inCarousel());
+ const before = await inCarousel();
+ await page.keyboard.press('ArrowDown');
+ check('a step down lands on it, past the one not ready', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), (await curClip()) + ' (carousel was: ' + before + ')');
+ check('…never a black slide: the clip landed on had its frame', (await groups()).find((g) => g.clip === 'tall3')?.poster === true);
+ const n0 = await counter();
+ check('the waiting post joins once its frame comes', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait'); }, null, 8000), await inCarousel());
+ await sleep(300);
+ const g = await groups();
+ check('…joining above the clip on screen without moving it', g.find((x) => x.cur)?.clip === 'tall3' && Math.abs(g.find((x) => x.cur).top) <= 1, JSON.stringify(g.map((x) => x.clip + ':' + x.top + (x.cur ? '*' : ''))));
+ check('…and the counter grew', (await counter()) !== n0, n0 + ' → ' + (await counter()));
+ await page.keyboard.press('ArrowUp');
+ check('a step back up lands on the one that joined', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall2/.test(s.getAttribute('data-k')) && !s.querySelector('video').paused; }, null, 6000), String(await curClip()));
+
+ console.log('\n[no flying with fewer than ten clips ready]');
+ await sleep(500);
+ const ready = (await inCarousel()).split(' ').length;
+ // a hard flick (600px in 80ms): a flight's worth, were flying allowed
+ await page.evaluate(() => {
+ const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
+ const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ const fire = (type, y, up) => target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] }));
+ const t0 = performance.now();
+ fire('touchstart', 700);
+ for (let i = 1; i <= 3; i++) { while (performance.now() < t0 + 80 * i / 3); fire('touchmove', 700 - 600 * i / 3); }
+ fire('touchend', 100, true);
+ });
+ check('with ' + ready + ' clips ready and none still warming, the counter shows (that is all there is)', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible'));
+ check('with ' + ready + ' clips ready, a hard flick from tall2 goes one post, to tall3', ready < 10 && await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), String(await curClip()));
+ await page.keyboard.press('ArrowUp');
+ await waitFor(() => /tall2/.test(document.querySelector('.mp-slide.mp-cur')?.getAttribute('data-k') || ''), null, 4000);
+
+ console.log('\n[the toggle in the pager]');
+ check('the pager\'s toggle says tall only', (await page.evaluate(() => document.querySelector('.mp-fit')?.textContent)) === 'Tall only');
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide clip\'s post is built (it joins once ready)', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-off'); }, null, 4000), await inCarousel());
+ check('…the clip on screen stayed put', (await curClip()) === 'tall2' && Math.abs((await groups()).find((x) => x.cur).top) <= 1, JSON.stringify((await groups()).map((x) => x.clip + ':' + x.top + (x.cur ? '*' : ''))));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: kept out for its shape', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-off'); }, null, 2000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ posts join the carousel ready to play, the screen\'s way round' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js
new file mode 100644
index 0000000..64ff7b5
--- /dev/null
+++ b/tools/pager-seek-hide-test.js
@@ -0,0 +1,129 @@
+// The video pager's seek bar shows only on the clip square on the screen:
+// while the pager is in motion — a glide to the next clip, a finger
+// dragging the slides, the snap back — no seek bar shows at all (it rode
+// along with the slide, a white line sliding up the screen), and it fades
+// back in once the clip has landed. The position counter is the other way
+// round: it shows while the pager moves and fades out once it has settled.
+// Run: bun tools/pager-seek-hide-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(wide|tall|last)\.webm$/.exec(path);
+ if (m) {
+ const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
+ if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1;
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } });
+ }
+ if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+// each slide's bar: its clip, whether it is the one on screen, whether the bar can be seen
+const bars = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const b = s.querySelector('.mp-seek'), cs = getComputedStyle(b); return { clip: s.getAttribute('data-k').split('/').pop(), cur: s.classList.contains('mp-cur'), seen: cs.visibility === 'visible' && +cs.opacity > 0.01, opacity: cs.opacity, ready: b.classList.contains('ready') }; }));
+const seenBars = async () => (await bars()).filter((x) => x.seen).map((x) => x.clip + '@' + x.opacity);
+// a recorder, frame by frame: the scroll position, the pager's motion flag, and any bar that can be seen
+const record = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'), el = document.querySelector('.media-pager'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push({ top: sc.scrollTop, moving: el.classList.contains('moving'), seen: [...document.querySelectorAll('.mp-seek')].filter((b) => { const cs = getComputedStyle(b); return cs.visibility === 'visible' && +cs.opacity > 0.01; }).length }); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); });
+const trace = () => page.evaluate(() => window.__tr);
+const counterOpacity = () => page.evaluate(() => +getComputedStyle(document.querySelector('.mp-position')).opacity);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
+ await sleep(400);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
+
+ console.log('\n[at rest: the clip on screen has its seek bar]');
+ check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
+ check('…and its seek bar shows', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && b.classList.contains('ready') && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars()));
+ check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' '));
+ check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
+ check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
+
+ console.log('\n[a glide to the next clip: no seek bar on the way]');
+ await record();
+ await page.keyboard.press('ArrowDown');
+ await sleep(1600);
+ const tr = await trace();
+ const between = tr.filter((f) => f.top > 2 && Math.abs(f.top - 844) > 2 && f.top < 844);
+ check('the glide was seen frame by frame', between.length >= 3, between.length + ' frames between the two clips');
+ check('…every one of them with the pager marked moving', between.every((f) => f.moving), between.map((f) => (f.moving ? 'm' : '-')).join(''));
+ check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join(''));
+ const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000);
+ check('it lands on the next clip', landedOn);
+ check('…whose seek bar fades back in', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars()));
+ check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars()));
+ check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
+
+ console.log('\n[a finger holding the slides off their mark: none either]');
+ await page.touchscreen.touchStart(195, 600);
+ await page.touchscreen.touchMove(195, 560); await sleep(30);
+ await page.touchscreen.touchMove(195, 500); await sleep(250);
+ check('held 100px up, the pager is marked moving', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('moving')));
+ check('…and no seek bar shows', (await seenBars()).length === 0, (await seenBars()).join(' '));
+ check('…and the position counter is back', (await counterOpacity()) === 1, String(await counterOpacity()));
+ await page.touchscreen.touchMove(195, 590); await sleep(30);
+ await page.touchscreen.touchEnd();
+ check('let go, it snaps back and the bar returns', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && getComputedStyle(b).opacity === '1' && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars()));
+ await sleep(500);
+ check('a moment after, the counter still shows', (await counterOpacity()) > 0.99, String(await counterOpacity()));
+ check('…then fades out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ no seek bar while the pager moves; it fades in on landing' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js
index d809793..657ea75 100644
--- a/tools/pager-swipe-fly-test.js
+++ b/tools/pager-swipe-fly-test.js
@@ -1,8 +1,10 @@
// On a phone, a fast flick up or down in the media pager flies past several
// posts and lands snapped on one, the way a spin of the wheel does; a
// gentler swipe is one post, as before; a fling at the end lands on the
-// last post rather than wrapping past it; a finger put down mid-flight
-// catches it. Run: bun tools/pager-swipe-fly-test.js
+// last post and goes no further (no going round to the first); a tap
+// mid-flight stops it on the post most on screen, and a swipe back from
+// there goes to the one before.
+// Run: bun tools/pager-swipe-fly-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
@@ -11,7 +13,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
-const N = 9, PIC = png('360x360');
+const N = 12, PIC = png('360x360');
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const html = await buildHtml({ minify: true, pwa: false });
@@ -26,6 +28,7 @@ const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, crea
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -33,7 +36,7 @@ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) {
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
// the post the pager rests square on (its number), or 'between'
const at = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); return s ? Number(/p(\d+)\.png/.exec(s.getAttribute('data-k'))[1]) : 'between'; });
-const settled = async () => { let last = null; for (let i = 0; i < 40; i++) { const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } };
+const settled = async () => { let last = null; for (let i = 0; i < 100; i++) { /* (a long flight takes up to 4 s) */ const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } };
// a swipe up the screen: px of travel over about `ms` milliseconds. A
// quick one is dispatched inside the page, as fast as the handlers take
// the moves (about 30–50 ms each here, headless): the browser's own touch
@@ -58,6 +61,23 @@ const swipe = async (px, ms, steps = 6) => {
fire('touchend', 700 - px, true);
}, [px, ms, steps]);
};
+// A swipe whose moves are made on schedule (each event stamped as the finger
+// moved) but handled late, the main thread busy: held back and delivered in
+// a burst at the end (burst), or the last move and the lift after a stall.
+const queued = (px, ms, steps, mode) => page.evaluate(([px, ms, steps, mode]) => {
+ const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
+ const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ const make = (type, y, up) => new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] });
+ const t0 = performance.now(), evs = [make('touchstart', 700)];
+ for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); evs.push(make('touchmove', 700 - px * i / steps)); }
+ evs.push(make('touchend', 700 - px, true));
+ if (mode === 'burst') { for (const e of evs) target.dispatchEvent(e); return; }
+ // stall: all but the last move handled as they come, then 300 ms busy
+ const tail = evs.splice(-2);
+ for (const e of evs) target.dispatchEvent(e);
+ const t1 = performance.now(); while (performance.now() < t1 + 300);
+ for (const e of tail) target.dispatchEvent(e);
+}, [px, ms, steps, mode]);
try {
await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true });
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
@@ -83,7 +103,8 @@ try {
const pic = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture post 1 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
await page.touchscreen.tap(pic.x, pic.y);
check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && (await (async () => { await settled(); return at(); })()) === 1, String(await at()));
- check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 8000));
+ check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 15000));
+ check('…and decoded, in the carousel (ten ready before it flies)', await waitFor((n) => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= n, N, 10000), String(await page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length)));
console.log('\n[a gentle swipe: one post]');
await swipe(320, 130); await settled();
@@ -91,33 +112,73 @@ try {
console.log('\n[a fast flick: several posts, landing snapped]');
const trace = async (sw) => { await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push(sc.scrollTop); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); await sw(); await sleep(1600); return page.evaluate(() => window.__tr); };
+ await page.evaluate(() => { const el = document.querySelector('.mp-position'); window.__counts = [el.textContent]; new MutationObserver(() => { if (window.__counts.at(-1) !== el.textContent) window.__counts.push(el.textContent); }).observe(el, { childList: true, characterData: true, subtree: true }); });
const tr = await trace(() => swipe(600, 80, 3)); await settled(); // three big moves: 3–6 px/ms as their handling takes, a long coast's worth (past the nine loaded, maybe)
const landed = await at();
check('a 600px flick in a few frames flies several posts on', landed !== 'between' && landed >= 5, String(landed));
check('…landing square on a post', landed !== 'between');
check('…in a glide, never back', tr.every((x, i) => !i || x >= tr[i - 1] - 0.5), tr.slice(0, 12).map((x) => Math.round(x)).join(' '));
+ check('the counter ran through the posts on the way, not jumping on landing', (await page.evaluate(() => window.__counts)).length >= 4, (await page.evaluate(() => window.__counts)).join(' → '));
const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent);
check('the counter says where it landed', seen === landed + ' / ' + N, seen);
- console.log('\n[a flick near the end: the last post, not past it]');
+ console.log('\n[the ends are ends]');
while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); }
const before = await at();
await swipe(600, 80, 3); await settled();
- check('from post ' + before + ' (a flight\'s worth would be past the end), a flick lands on the last post', before >= 7 && (await at()) === N, String(await at()));
+ check('from post ' + before + ', a flick whose flight would be past the end lands on the last post', before === 7 && (await at()) === N, String(await at()));
await swipe(600, 80, 3); await settled();
- check('…and one more flick at the end wraps to the first', (await at()) === 1, String(await at()));
+ check('…a flick on from the last stays there (no going round to the first)', (await at()) === N, String(await at()));
+ await page.keyboard.press('ArrowDown'); await settled();
+ check('…and so does ↓', (await at()) === N, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await swipe(-600, 80, 3); await settled();
+ check('a flick back up from the first stays on the first', (await at()) === 1, String(await at()));
+ await swipe(320, 130); await settled();
+ check('a gentle swipe from the first: the second, as ever', (await at()) === 2, String(await at()));
+
+ console.log('\n[a busy main thread: the speed is the finger\'s, not the handling\'s]');
+ // (the dispatch loop runs all the events in one go: built on schedule, each stamped as it was made)
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await queued(200, 200, 8, 'burst'); await settled();
+ check('from post 1, a gentle 200px swipe over 200ms, its moves handled in one burst: one post on', (await at()) === 2, String(await at()));
+ await queued(600, 80, 4, 'stall'); await settled();
+ check('a fast flick whose last move was handled 300ms late still flies', typeof (await at()) === 'number' && (await at()) >= 5, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+
+ console.log('\n[a tap mid-flight stops it on the post most on screen]');
+ // the post most on screen as the finger comes down (its number), then the touch, in one go
+ const down = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'), y = sc.scrollTop;
+ const g = [...sc.querySelectorAll(':scope > .mp-group:not(.mp-wait):not(.mp-off)')].reduce((b, x) => (Math.abs(x.offsetTop - y) < Math.abs(b.offsetTop - y) ? x : b));
+ const target = window.__tt = document.elementFromPoint(195, 500), t = new Touch({ identifier: 2, target, clientX: 195, clientY: 500, pageX: 195, pageY: 500 });
+ target.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], changedTouches: [t], targetTouches: [t] }));
+ return Number(/p(\d+)\.png/.exec(g.querySelector('.mp-slide').getAttribute('data-k'))[1]);
+ });
+ // the same finger moving (to y), or lifted there
+ const finger = (y, up) => page.evaluate(([y, up]) => {
+ const target = window.__tt, t = new Touch({ identifier: 2, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ target.dispatchEvent(new TouchEvent(up ? 'touchend' : 'touchmove', { bubbles: true, cancelable: true, touches: up ? [] : [t], changedTouches: [t], targetTouches: up ? [] : [t] }));
+ }, [y, up]);
+ await swipe(600, 80, 3);
+ await sleep(150);
+ const flying = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
+ const most = await down();
+ await sleep(60); await finger(500, true);
+ await sleep(250);
+ check('a tap mid-flight: on the post most on screen in a moment', flying > 0 && (await at()) === most, most + ' wanted, ' + (await at()));
+ await settled();
+ check('…and stays there', (await at()) === most, String(await at()));
+ check('…the tap only stopped it (no details brought up)', !(await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))));
- console.log('\n[a finger down mid-flight catches it]');
+ console.log('\n[a swipe back mid-flight: the post before]');
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
await swipe(600, 80, 3);
- await sleep(120);
- const mid = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
- await page.touchscreen.touchStart(195, 500);
- await page.touchscreen.touchMove(195, 480); await sleep(30);
- await page.touchscreen.touchMove(195, 470); await sleep(200);
- const held = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
- await page.touchscreen.touchEnd(); await settled();
- check('the flight stops under the finger', mid > 0 && Math.abs(held - mid) < 844, mid + ' → ' + held);
- check('…and lands square on a post when it lifts', (await at()) !== 'between', String(await at()));
+ await sleep(150);
+ const most2 = await down();
+ for (const y of [520, 560, 640, 760]) { await finger(y); await sleep(20); }
+ await finger(760, true); await settled();
+ check('caught on post ' + most2 + ' and swiped back: post ' + (most2 - 1), most2 > 1 && (await at()) === most2 - 1, String(await at()));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a fast flick flies through the pager' : '\n❌ failed');
diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js
index 09f761a..4de87df 100644
--- a/tools/pager-tap-slop-test.js
+++ b/tools/pager-tap-slop-test.js
@@ -76,6 +76,7 @@ try {
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ localStorage.setItem('coinos-clips-fit', 'all'); // (the wide clip stays in: this test is about the finger, not the shapes)
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js
new file mode 100644
index 0000000..7cfaa45
--- /dev/null
+++ b/tools/picture-pager-ready-test.js
@@ -0,0 +1,102 @@
+// The picture pager works like the video pager (Adam, 2026-10-17: "image
+// pager should work similar. we need the tall only chip there too"): a
+// picture post joins the carousel once its picture is decoded, so a step
+// passes over one still on its way; the shapes the wrong way round for
+// the screen stay out, with the "Tall only" / "All shapes" toggle in the
+// top bar; and with fewer than ten ready and more still warming, no
+// counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+// t1..t4 tall, w1 wide, sq square; slow (tall) is held back until released
+const PICS = { t1: png('300x400'), t2: png('300x420'), slow: png('300x440'), t3: png('300x460'), w1: png('480x270'), sq: png('320x320'), t4: png('300x480') };
+let release; const held = new Promise((r) => (release = r));
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(\w+)\.png$/.exec(path);
+ if (m && PICS[m[1]]) { if (m[1] === 'slow') await held; return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const ORDER = ['t1', 'slow', 't2', 'w1', 'sq', 't3', 't4'];
+const seeded = ORDER.map((n, i) => post('picture ' + n + ' ' + U(n), 100 + i * 100));
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const name = (k) => k.split('|')[1].split('/').pop().replace('.png', '');
+const carousel = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' '));
+const cur = () => page.evaluate(() => { const s = document.querySelector('.mp-slide.mp-cur'); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : null; });
+const resting = (n) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 2 && Math.abs(r.left) < 2; }); return !!s && s.getAttribute('data-k').endsWith('/' + n + '.png'); }, n, 5000);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 7, null, 20000));
+ await waitFor(() => !!document.querySelector('.notes-feed img.note-img'), null, 10000);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(400);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide'), null, 4000) && await resting('t1'), await cur());
+
+ console.log('\n[a picture joins once decoded]');
+ check('the pictures decoded join: t2, t3, t4 and the square', await waitFor(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= 5, null, 8000), await carousel());
+ check('the one still on its way waits out of the carousel', !(await carousel()).includes('slow'), await carousel());
+ check('the wide one is out for its shape', !(await carousel()).includes('w1'), await carousel());
+ check('with fewer than ten ready and one still warming, no counter shows', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'hidden'));
+ await page.keyboard.press('ArrowDown');
+ check('down from t1 passes over it, to t2', await resting('t2'), await cur());
+ release();
+ check('once it has come, it joins, above the one on screen', await waitFor(() => /t1 slow t2/.test([...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' ')), null, 8000), await carousel());
+ check('…without moving it', await resting('t2'), await cur());
+ check('everything in, the counter shows: 3 / 6', await waitFor(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible' && document.querySelector('.mp-position').textContent === '3 / 6', null, 3000), await page.evaluate(() => document.querySelector('.mp-position').textContent));
+ await page.keyboard.press('ArrowUp');
+ check('up lands on it', await resting('slow'), await cur());
+
+ console.log('\n[the shape toggle in the picture pager]');
+ const label = await page.evaluate(() => document.querySelector('.media-pager .mp-fit')?.textContent);
+ check('the top bar has the toggle, saying tall only', label === 'Tall only', String(label));
+ check('…as tall as the close button', await page.evaluate(() => document.querySelector('.mp-fit').offsetHeight === document.querySelector('.mp-x').offsetHeight));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide picture joins', await waitFor(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].some((g) => /w1\.png$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))), null, 6000), await carousel());
+ check('…and the toggle says all shapes', (await page.evaluate(() => document.querySelector('.mp-fit').textContent)) === 'All shapes');
+ check('…the picture on screen stayed put', await resting('slow'), await cur());
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: the wide one is out', await waitFor(() => !document.querySelector('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off) .mp-slide[data-k$="/w1.png"]'), null, 3000), await carousel());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { release(); await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the picture pager: decoded pictures only, the screen\'s way round, with the toggle' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js
index 29341e7..2a78598 100644
--- a/tools/profile-pager-test.js
+++ b/tools/profile-pager-test.js
@@ -36,6 +36,7 @@ const seeded = [
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -104,18 +105,15 @@ try {
await page.keyboard.press('ArrowDown');
check('down again: her last', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowDown'); await sleep(600);
- check('past her last wraps to her first post', await resting('a2.png'), await curPic());
- check('counter counts the first post once', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
+ check('past her last it stays on her last (no going round)', await resting('d.png'), await curPic());
+ check('counter still at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
check('no loading slide', await page.$('.mp-tail') === null);
- await page.keyboard.press('ArrowUp'); await resting('d.png');
- check('up from first wraps to last', await curPic() === 'd.png');
- check('counter at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
await page.touchscreen.touchStart(195, 650);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); }
- await page.touchscreen.touchEnd();
- check('touch swipe wraps to first post', await resting('a2.png'), await curPic());
- await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true })));
- check('wheel wraps back to last post', await resting('d.png'), await curPic());
+ await page.touchscreen.touchEnd(); await sleep(600);
+ check('a touch swipe on from her last stays there', await resting('d.png'), await curPic());
+ await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: 3, deltaMode: 1, bubbles: true, cancelable: true }))); await sleep(600);
+ check('…and so does the wheel', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowUp');
check('up: her post before', await resting('b.png'), await curPic());
await page.keyboard.press('Escape');
diff --git a/tools/quote-pager-test.js b/tools/quote-pager-test.js
index 5d9e536..7f8b76c 100644
--- a/tools/quote-pager-test.js
+++ b/tools/quote-pager-test.js
@@ -39,6 +39,7 @@ const ALL = [...FEED, quotedPost];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
await page.evaluateOnNewDocument((evs) => {
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
&& (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#'));
diff --git a/tools/registry-ownership-test.js b/tools/registry-ownership-test.js
deleted file mode 100644
index 43683a1..0000000
--- a/tools/registry-ownership-test.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import assert from 'node:assert/strict';
-import { registrationIdentity } from '../names/ownership.js';
-
-const owner = 'a'.repeat(64), wallet = 'b'.repeat(64), stranger = 'c'.repeat(64);
-const grant = { pubkey: owner, manager: wallet };
-for (const signer of [owner, wallet]) {
- assert.deepEqual(registrationIdentity({ signer, grant, granted: true }), { pubkey: owner, manager: wallet });
-}
-console.log('✓ owner and manager registrations both preserve the migration identity');
-assert.deepEqual(registrationIdentity({ signer: wallet, manager: wallet, grant, granted: true }), { pubkey: owner, manager: wallet });
-console.log('✓ the signing wallet can remain the nominated manager');
-const existing = { pubkey: owner, manager: wallet };
-assert.deepEqual(registrationIdentity({ existing, signer: wallet, grant: { pubkey: stranger }, granted: false }), existing);
-console.log('✓ later wallet updates preserve existing ownership and management');
-assert.deepEqual(registrationIdentity({ signer: wallet, grant, granted: false }), { pubkey: wallet, manager: undefined });
-assert.deepEqual(registrationIdentity({ signer: owner, manager: wallet }), existing);
-assert.deepEqual(registrationIdentity({ signer: owner, manager: owner }), { pubkey: owner, manager: undefined });
-console.log('✓ ordinary claims retain their signer and ignore unauthorized grants');
diff --git a/tools/sync-blob-test.js b/tools/sync-blob-test.js
index ee1c60a..e8e25cb 100644
--- a/tools/sync-blob-test.js
+++ b/tools/sync-blob-test.js
@@ -1,6 +1,6 @@
// Oversized sync domains on Blossom: the envelope, the server's policy, the
// outbox's upload-then-publish flow, and a fresh device resolving a pointer.
-// Spawns blossom/server.js on a free port. Run: bun tools/sync-blob-test.js
+// Spawns coinosv3-server's blossom/server.js on a free port. Run: bun tools/sync-blob-test.js
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -30,7 +30,13 @@ const a = signer(), b = signer('someone else');
// ---- the server ------------------------------------------------------------
const port = 20000 + Math.floor(Math.random() * 10000);
const dataDir = mkdtempSync(join(tmpdir(), 'blossom-test-'));
-const server = Bun.spawn(['bun', new URL('../blossom/server.js', import.meta.url).pathname], {
+// The Blossom server lives in the coinosv3-server repo (a sibling checkout,
+// or COINOSV3_SERVER); this test is the wallet's side of the sync.
+import { existsSync } from 'node:fs';
+const serverRepo = process.env.COINOSV3_SERVER || join(process.env.HOME, 'coinosv3-server');
+const blossomJs = join(serverRepo, 'blossom', 'server.js');
+if (!existsSync(blossomJs)) { console.log(`skipped: no blossom server at ${blossomJs} (clone coinosv3-server beside this repo, or set COINOSV3_SERVER)`); process.exit(0); }
+const server = Bun.spawn(['bun', blossomJs], {
env: { ...process.env, BLOSSOM_DATA: dataDir, BLOSSOM_PORT: String(port), BLOSSOM_HOST: 'localhost', BLOSSOM_QUOTA_COUNT: '2', BLOSSOM_MAX_BLOB: String(200_000) },
stdout: 'ignore', stderr: 'inherit',
});
diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js
new file mode 100644
index 0000000..bd80408
--- /dev/null
+++ b/tools/thread-instant-test.js
@@ -0,0 +1,158 @@
+// A thread opens whole, at once, for a post that has been on screen: the
+// feed already fetches every post's replies for the count, and the thread
+// is seeded from them — the relays only add to it. A thread with nothing in
+// hand paints its replies as they arrive, not after the slowest relay.
+// Run: bun tools/thread-instant-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB), SKC = generateSecretKey(), CAROL = getPublicKey(SKC);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const A1 = post('alice asks the room a question', 900, SKA);
+const B1 = post('bob posts something quieter', 800, SKB);
+const R1 = post('bob answers alice first', 700, SKB, [['e', A1.id, '', 'root'], ['p', ALICE]]);
+const R2 = post('carol answers alice too', 600, SKC, [['e', A1.id, '', 'root'], ['p', ALICE]]);
+const R3 = post('alice answers bob back', 500, SKA, [['e', A1.id, '', 'root'], ['e', R1.id, '', 'reply'], ['p', BOB]]);
+const S1 = post('carol on bob, quick relay', 400, SKC, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const S2 = post('alice on bob, slow relay', 300, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const C1 = post('carol posts last, fetched ahead', 200, SKC);
+const U1 = post('bob on carol, held back', 100, SKB, [['e', C1.id, '', 'root'], ['p', CAROL]]);
+const html = await buildHtml({ minify: true, pwa: false });
+const FACE = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=96x96', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ if (path === '/bob.png') return new Response(FACE, { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// The relays, answered in the page. The feed's count fetch (one REQ, no
+// limit, every post on screen) is held until the test releases it: then
+// alice's three replies and carol's one land, bob's none. A thread's own
+// ask (a limit): alice's answered after 3 s, carol's after 6 s, bob's one
+// reply at 300 ms and the other at 2.5 s, EOSE after.
+await page.evaluateOnNewDocument((fx) => {
+ window.__reqs = [];
+ // the thread prefetch (on at first) is switched off (data saver) once the
+ // part of the test that wants it is done
+ window.__conn = { saveData: false };
+ Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true });
+ window.__held = [];
+ window.__releaseC = () => { for (const f of window.__held) f(); window.__held = []; };
+ const Real = window.WebSocket;
+ const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); };
+ const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); };
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const fs = m.slice(2);
+ window.__reqs.push({ t: Date.now(), fs });
+ const on = (id) => fs.find((f) => (f['#e'] || []).includes(id) && (f.kinds || []).includes(1));
+ const onA = on(fx.A1), onB = on(fx.B1), onC = on(fx.C1);
+ const count = [onA, onB, onC].find((f) => f && !f.limit);
+ if (count) { window.__held.push(() => { for (const ev of [...fx.aReplies, ...fx.cReplies]) send1(ws, m[1], ev); eose(ws, m[1]); }); return; }
+ if (onC && onC.limit) { setTimeout(() => { send1(ws, m[1], fx.cReplies[0]); eose(ws, m[1]); }, 6000); return; }
+ if (onA && onA.limit) { setTimeout(() => { for (const ev of fx.aReplies) send1(ws, m[1], ev); eose(ws, m[1]); }, 3000); return; }
+ if (onB && onB.limit) { setTimeout(() => send1(ws, m[1], fx.bReplies[0]), 300); setTimeout(() => { send1(ws, m[1], fx.bReplies[1]); eose(ws, m[1]); }, 2500); return; }
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, { A1: A1.id, B1: B1.id, C1: C1.id, aReplies: [R1, R2, R3], bReplies: [S1, S2], cReplies: [U1] });
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
+const thread = () => page.evaluate(() => ({
+ rows: [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')),
+ spinner: !!document.querySelector('.thread-card .spinner'),
+ text: document.querySelector('.thread-card')?.innerText || '',
+ face: [...document.querySelectorAll('.thread-page [data-zap-post]')].slice(-1)[0]?.querySelector('.note-avatar')?.style.backgroundImage || '',
+}));
+const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, alice, bob, carol, notes]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(notes));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Asker', t: Date.now() }, [bob]: { name: 'Bob Quiet', picture: location.origin + '/bob.png', t: Date.now() }, [carol]: { name: 'Carol Quick', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, CAROL, [C1, B1, A1]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the feed paints the posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 3, null, 20000));
+ console.log('\n[fetched ahead before the count answered]');
+ check('reading carol\'s post for a moment fetches its thread ahead', await waitFor((id) => window.__reqs.some((r) => r.fs.some((f) => (f['#e'] || []).includes(id) && f.limit)), C1.id, 15000));
+ await page.evaluate(() => { window.__conn.saveData = true; }); // no more fetching ahead: the later parts want their threads untouched
+ await sleep(300);
+ await page.evaluate(() => window.__releaseC()); // now the count answer lands, after the thread entry was made
+ check('…and the count answer brings its reply', await waitFor((id) => /\b1\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), C1.id, 8000));
+ const t2 = Date.now();
+ await openRow(C1.id);
+ let ahead = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.includes(U1.id)) { ahead = { ...th, ms: Date.now() - t2 }; break; } await sleep(25); }
+ check('the thread opens with the reply from the count, not after its own 6 s ask', !!ahead && ahead.ms < 1500 && !ahead.spinner, ahead ? ahead.ms + ' ms' : 'never');
+ check('…painted in its final shape: bob\'s face was fetched ahead, on the row at its first paint', !!ahead && /blob:|data:image/.test(ahead.face || ''), ahead ? String(ahead.face).slice(0, 60) : '');
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ check('the count fetch brought alice\'s replies', await waitFor((id) => /\b3\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), A1.id, 15000), 'reply count on the row');
+ const asked = await page.evaluate(() => window.__reqs.length);
+
+ console.log('\n[a post that has been on screen]');
+ const t0 = Date.now();
+ await openRow(A1.id);
+ let first = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.length >= 4) { first = { ...th, ms: Date.now() - t0 }; break; } await sleep(25); }
+ check('the thread opens whole, at once — the replies in hand, no spinner', !!first && first.ms < 1500 && first.rows[0] === A1.id && [R1.id, R2.id, R3.id].every((id) => first.rows.includes(id)) && !first.spinner, first ? first.ms + ' ms, ' + first.rows.length + ' rows, spinner ' + first.spinner : 'never');
+ check('…before the relays answered the thread\'s own ask', (Date.now() - t0) < 2500);
+ await sleep(3500);
+ const later = await thread();
+ check('the relays\' answer adds nothing twice; no spinner, no "no replies"', later.rows.length === 4 && !later.spinner && !/no replies/i.test(later.text), later.rows.length + ' rows');
+ await page.goBack(); await sleep(800);
+ await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000);
+
+ console.log('\n[a post with nothing in hand]');
+ const t1 = Date.now();
+ await openRow(B1.id);
+ let opened = null;
+ for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows[0] === B1.id) { opened = { ...th, ms: Date.now() - t1 }; break; } await sleep(20); }
+ check('the post reads at once: no spinner, no "no replies" while they are on their way', !!opened && !opened.spinner && !/no replies/i.test(opened.text), opened ? opened.ms + ' ms' : 'never');
+ let streamed = null, spun = false;
+ for (let i = 0; i < 80; i++) { const th = await thread(); spun ||= th.spinner; if (th.rows.includes(S1.id)) { streamed = { ...th, ms: Date.now() - t1 }; break; } await sleep(25); }
+ check('the first reply pops in as it arrives, the slow one still on its way', !!streamed && streamed.ms < 2500 && !streamed.rows.includes(S2.id), streamed ? streamed.ms + ' ms' : 'never');
+ check('…never a spinner', !spun && !!streamed && !streamed.spinner);
+ check('the slow one lands in its place', await waitFor((id) => { const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return rows.length === 3 && rows[2] === id && !document.querySelector('.thread-card .spinner'); }, S2.id, 6000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ threads open at once from the replies in hand, and fill in as they arrive' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/thread-prefetch-test.js b/tools/thread-prefetch-test.js
index 6329e64..22107c6 100644
--- a/tools/thread-prefetch-test.js
+++ b/tools/thread-prefetch-test.js
@@ -15,7 +15,7 @@ const row = (id, top = 0, height = 300) => ({ id, top, height,
});
const tick = async (ms = 250) => { time += ms; const fn = task; task = null; fn?.(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
const watch = (...rs) => { rows = rs; for (const r of rs) p.watch({ id: r.id }); };
-const dwell = async () => { for (let i = 0; i < 7; i++) await tick(); };
+const dwell = async () => { for (let i = 0; i < 4; i++) await tick(); }; // a second: past the 600 ms
watch(row('passing')); await tick(); rows = []; await dwell(); assert.deepEqual(calls, []);
watch(row('moving')); for (let i = 0; i < 8; i++) { rows[0].top += 30; await tick(); } assert.deepEqual(calls, []);
watch(row('hidden')); doc.hidden = true; await dwell(); assert.deepEqual(calls, []); doc.hidden = false;
@@ -24,8 +24,8 @@ watch(row('tall', -200, 1800), row('second', 100)); await dwell(); assert.deepEq
await dwell(); assert.deepEqual(calls, ['tall'], 'only one in-flight fetch');
resolve(); await tick(); await tick(); assert.deepEqual(calls, ['tall', 'second']);
resolve(); await tick(); await dwell(); assert.equal(calls.length, 2, 'cached posts are reused');
-for (let i = 0; i < 5; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); }
-assert.equal(calls.length, 6, 'six starts per minute');
-await tick(60000); assert.equal(calls.length, 7); resolve(); await tick();
-watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 7);
+for (let i = 0; i < 11; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); }
+assert.equal(calls.length, 12, 'twelve starts per minute');
+await tick(60000); assert.equal(calls.length, 13); resolve(); await tick();
+watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 13);
console.log('✓ dwell, fast scrolling, unmount, hidden tab, data saver, tall posts, concurrency, cache, rate limit and stop');
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
index 85250c0..93726e1 100644
--- a/tools/video-fallback-test.js
+++ b/tools/video-fallback-test.js
@@ -37,6 +37,12 @@ const server = Bun.serve({ port: 0, fetch: (req) => {
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
+// a browser without HLS of its own (Firefox): a clip too big to play as it
+// is waits for a tap here, rather than streaming (video-hls-play-test)
+await page.evaluateOnNewDocument(() => {
+ const can = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : can.call(this, t); };
+});
await page.setRequestInterception(true);
const asked = []; // [kind, clip url]
page.on('request', (r) => {
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
new file mode 100644
index 0000000..f1aea1c
--- /dev/null
+++ b/tools/video-hls-play-test.js
@@ -0,0 +1,133 @@
+// A long clip in the feed plays as HLS from vmedia (a rendition the
+// connection carries, in pieces made as watched), and so does one too big
+// to play as it is (past 1080p: it used to wait for a tap); a short one as it is,
+// and a post linking an .m3u8 stream plays that. vmedia runs here for real,
+// on a local copy of the clip.
+// Run: bun tools/video-hls-play-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { readFileSync, rmSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const DIR = process.env.CLIP_DIR || '/tmp';
+const ff = (out, codec) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3',
+ '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
+ return readFileSync(out);
+};
+const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']);
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']);
+const LONG = readFileSync(DIR + '/coinos-hls-long.mp4');
+// short but past 1080p: it used to wait for a tap with its cost on it
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=1440x1440:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-big.mp4']);
+const BIG = readFileSync(DIR + '/coinos-hls-big.mp4');
+// vmedia itself, reading the clip from here
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => new Response(new URL(req.url).pathname === '/big.mp4' ? BIG : LONG) });
+const local = (src) => src.replace('https://media.test/', `http://127.0.0.1:${files.port}/`);
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = DIR + '/coinos-hls-vmedia';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8', big: 'https://media.test/big.mp4' };
+const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300), post('square ' + CLIP.big, 400)];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = []; // [kind, clip url]
+// vmedia's answers, from the real thing; the clip's host serves the files
+const vmedia = async (u) => {
+ if (u.pathname === '/vmedia/hls.m3u8') u.searchParams.set('u', local(u.searchParams.get('u')));
+ const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search));
+ return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } };
+};
+page.on('request', async (r) => {
+ const u = new URL(r.url());
+ if (u.pathname.startsWith('/vmedia/hls')) {
+ asked.push(['hls', u.pathname]);
+ return r.respond(await vmedia(u));
+ }
+ if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
+ if (u.host === 'media.test') {
+ asked.push([r.resourceType(), r.url()]);
+ // the stream a post links: vmedia's of the long clip, served from its host
+ if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); }
+ return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : u.pathname === '/big.mp4' ? BIG : SHORT, headers: { 'access-control-allow-origin': '*' } });
+ }
+ r.continue();
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`;
+const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video');
+ return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`));
+const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the four clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 4, null, 20000),
+ String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length)));
+
+ console.log('\n[a long clip]');
+ await show('lecture');
+ check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('lecture')));
+ check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4)));
+
+ console.log('\n[a short clip past 1080p]');
+ await page.evaluate(new Function(`${box('square')}.scrollIntoView({ block: 'center' })`));
+ check('plays by itself as HLS, no tap-to-play cover', await waitFor(new Function(`const b = ${box('square')}, v = b?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.3 && !b.querySelector('.vid-heavy');`), null, 20000),
+ JSON.stringify(await player('square')));
+
+ console.log('\n[a short clip]');
+ await show('quick');
+ check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000),
+ JSON.stringify(await player('quick')));
+
+ console.log('\n[a post linking a stream]');
+ await show('episode');
+ check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('episode')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); }
+console.log(ok ? '\n✅ long clips and streams play as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js
new file mode 100644
index 0000000..05961a8
--- /dev/null
+++ b/tools/vmedia-hls-test.js
@@ -0,0 +1,84 @@
+// vmedia's HLS: a clip's playlists come from its length before anything is
+// made, segments are made from wherever the player asks (a seek starts the
+// encoder there), the encoder waits once well ahead of the player, and
+// Chrome plays the stream and seeks in it.
+// Run: bun tools/vmedia-hls-test.js
+import puppeteer from './puppeteer.js';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/vmedia-hls-');
+const clip = dir + '/clip.mp4';
+// a minute of 1080p with sound
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]);
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) });
+const SRC = `http://127.0.0.1:${files.port}/clip.mp4`;
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = dir + '/data';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle });
+const V = `http://127.0.0.1:${api.port}/vmedia`;
+const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers });
+ return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } });
+} });
+let browser;
+try {
+ console.log('[the playlists]');
+ const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text();
+ check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300));
+ const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1];
+ const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text();
+ const segs = media.split('\n').filter((l) => /\.ts$/.test(l));
+ check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments');
+ check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720'));
+ check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400);
+
+ console.log('\n[segments, made as asked]');
+ let t0 = Date.now();
+ const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`);
+ const b0 = await s0.arrayBuffer();
+ check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms');
+ t0 = Date.now();
+ const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`);
+ await s10.arrayBuffer();
+ check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms');
+ const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format;
+ const p0 = probe(0), p12 = probe(12);
+ check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' → ' + p12.start_time);
+ await sleep(4000);
+ const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f));
+ check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' '));
+
+ console.log('\n[Chrome plays it]');
+ browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+ const page = await browser.newPage();
+ await page.goto(page0.url.href);
+ const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`;
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration };
+ v.currentTime = 30;
+ const t1 = performance.now();
+ while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, hlsSrc);
+ check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first));
+ check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur));
+ check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ if (browser) await browser.close();
+ files.stop(true); api.stop(true); page0.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ vmedia serves long clips as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/vmedia/server.js b/vmedia/server.js
index b2f5bae..6b6b69a 100644
--- a/vmedia/server.js
+++ b/vmedia/server.js
@@ -4,6 +4,9 @@
// /vmedia/thumb.jpg?u=<clip> a still from the clip's first half second,
// 480px wide — the poster a post's imeta didn't give. A 4K or 134 MB
// clip waits for a tap, so without one the feed showed a black box.
+// /vmedia/first.jpg?u=<clip> the clip's very first frame, at its size
+// (longest side 1280): the very picture a player shows first, so the
+// video pager's slide ahead holds it before the player has it.
// /vmedia/h264.mp4?u=<clip> the clip as H.264/AAC at 720p, for a browser
// that can't decode the original's picture (an iPhone's HEVC on Chrome
// for Linux plays its sound over black). The first ask streams the
@@ -17,18 +20,26 @@
// camera file froze a wheel scroll for 400 ms on its texture upload.
// libvips makes the copy (shrink-on-load: the original is never decoded
// whole), turned the way its EXIF says, stripped, as WebP.
+// /vmedia/hls.m3u8?u=<clip> the clip as HLS (Adam, 2026-10-18; jb55: "you
+// should use hls (m3u8 ext) for long videos"): a 360p and a 720p H.264
+// rendition in 4-second segments, the player choosing between them as
+// the connection allows. Nothing is made ahead of time: the playlists
+// are written from the clip's length, and each rendition's encoder runs
+// from the segment asked for, a minute ahead of the player at most (then
+// paused), restarted wherever a seek lands, and stopped once nobody is
+// asking. Segments made are kept, like the copies.
//
// Only https sources on public addresses; ffmpeg may speak nothing else. A
// few jobs at a time, a per-address hourly allowance for new transcodes,
// and both caches trimmed to a size.
-import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync } from 'node:fs';
+import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { createHash } from 'node:crypto';
const DATA = process.env.DATA || '/data';
-const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work';
-for (const d of [THUMBS, CLIPS, PICS, WORK]) mkdirSync(d, { recursive: true });
+const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work', HLS = DATA + '/hls';
+for (const d of [THUMBS, CLIPS, PICS, WORK, HLS]) mkdirSync(d, { recursive: true });
const THUMB_CAP = 2e9, CLIP_CAP = 25e9, PIC_CAP = 8e9;
const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6, PIC_JOBS = 4, PIC_QUEUE = 60;
const CLIP_PER_HOUR = 12; // new transcodes per address
@@ -52,9 +63,11 @@ const privateV6 = (ip) => {
};
export const privateIp = (ip) => (isIP(ip) === 6 ? privateV6(ip) : privateV4(ip));
// a clip worth fetching: https, an ordinary port, a public name
+const LOCAL = process.env.VMEDIA_LOCAL === '1'; // tests: clips served over http from this machine
export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
let u;
try { u = new URL(raw); } catch { return null; }
+ if (LOCAL && u.protocol === 'http:' && u.hostname === '127.0.0.1') return u.href;
if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443') || raw.length > 2000) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) return null;
@@ -66,7 +79,7 @@ export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
}
// ---- ffmpeg ---------------------------------------------------------------
-const NET = ['-protocol_whitelist', 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia'];
+const NET = ['-protocol_whitelist', LOCAL ? 'http,https,tls,tcp,crypto' : 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia'];
// longest side at most 1280, even numbers for the encoder
export const SCALE_720 = "scale='trunc(min(1,1280/max(iw,ih))*iw/2)*2':'trunc(min(1,1280/max(iw,ih))*ih/2)*2'";
export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src,
@@ -74,6 +87,9 @@ export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel'
// that fades in from black has a black frame at half a second
'-frames:v', '1', '-vf', "scale='min(480,iw)':-2," + (lift ? 'normalize=strength=0.7:smoothing=0,' : '') + "signalstats,metadata=mode=print:key=lavfi.signalstats.YAVG:file='pipe\\:2'",
'-q:v', '5', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1'];
+// frame 0 itself, as big as the player shows it (the pager's slide ahead)
+export const firstArgs = (src) => ['-hide_banner', '-loglevel', 'error', ...NET, '-i', src,
+ '-frames:v', '1', '-vf', SCALE_720, '-q:v', '3', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1'];
export const clipArgs = (src, out) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET, '-i', src,
'-map', '0:v:0', '-map', '0:a:0?', '-t', String(MAX_SECONDS), '-vf', SCALE_720,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-threads', '4',
@@ -131,6 +147,26 @@ async function makeThumb(src, key) {
failed.set(key, Date.now());
return null;
}
+// The very first frame, full size, for the video pager: the slide ahead
+// holds the picture its player will show first, so a flick glides onto it
+// and the player takes over with no visible change. Dark is dark: this one
+// is meant to match the player, not to show what the clip is.
+async function makeFirst(src, key) {
+ const out = `${THUMBS}/${key}-f.jpg`;
+ const { p, done } = run(firstArgs(src), 25_000);
+ const [buf] = await Promise.all([new Response(p.stdout).arrayBuffer(), done]);
+ if (p.exitCode === 0 && buf.byteLength > 500) { await Bun.write(out, buf); return out; }
+ failed.set(key, Date.now());
+ return null;
+}
+const FIRST_V = 1;
+async function first(src) {
+ const key = keyOf('f' + FIRST_V + '|' + src), out = `${THUMBS}/${key}-f.jpg`;
+ if (existsSync(out)) { touch(out); return out; }
+ if (failedLately(key)) return null;
+ if (!thumbJobs.has(key)) thumbJobs.set(key, thumbSlots(() => makeFirst(src, key)).finally(() => thumbJobs.delete(key)));
+ return thumbJobs.get(key);
+}
const THUMB_V = 4; // v4: past a dark opening frame (stills made before are made again)
async function thumb(src) {
const key = keyOf('v' + THUMB_V + '|' + src), out = `${THUMBS}/${key}.jpg`;
@@ -202,6 +238,171 @@ function tail(job) {
});
}
+// ---- HLS --------------------------------------------------------------------
+const SEG = 4; // seconds a segment
+const HLS_CAP = 30e9, HLS_ENCODERS = 6, HLS_PER_HOUR = 30, HLS_MAX_SECONDS = 4 * 3600;
+const AHEAD = 15; // segments an encoder runs ahead of the player before it waits
+const IDLE_MS = 90_000; // an encoder nobody has asked anything of for this long stops
+const RUNGS = [{ name: '360', long: 640, vb: 800, ab: 96 }, { name: '720', long: 1280, vb: 2800, ab: 128 }];
+const HLS_V = 1;
+const hlsKey = (src) => keyOf('h' + HLS_V + '|' + src);
+const hlsAllowance = new Map();
+const even = (x) => Math.max(2, Math.round(x / 2) * 2);
+// the rendition sizes a clip gets: never larger than it is; one at least
+export function rungsFor(w, h) {
+ const long = Math.max(w, h);
+ const out = RUNGS.filter((r, i) => !i || long >= r.long * 0.9).map((r) => {
+ const k = Math.min(1, r.long / long);
+ return { ...r, w: even(w * k), h: even(h * k) };
+ });
+ return out;
+}
+// what ffprobe says: its length, its picture's size the way it's shown, any sound
+export function readProbe(j) {
+ const v = (j.streams || []).find((s) => s.codec_type === 'video');
+ const dur = Number(j.format?.duration);
+ if (!v || !v.width || !v.height || !(dur > 0)) return null;
+ const rot = Number((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0);
+ const turned = Math.abs(rot) % 180 === 90;
+ return { dur, w: turned ? v.height : v.width, h: turned ? v.width : v.height, audio: (j.streams || []).some((s) => s.codec_type === 'audio') };
+}
+const probing = new Map();
+async function hlsInfo(src, key) {
+ const dir = `${HLS}/${key}`, file = `${dir}/info.json`;
+ if (existsSync(file)) { touch(dir); try { return JSON.parse(readFileSync(file, 'utf8')); } catch {} }
+ if (failedLately(key)) return null;
+ if (!probing.has(key)) probing.set(key, (async () => {
+ const p = Bun.spawn(['ffprobe', '-v', 'error', ...NET, '-show_format', '-show_streams', '-of', 'json', src], { stdout: 'pipe', stderr: 'ignore' });
+ const timer = setTimeout(() => p.kill(9), 25_000);
+ const out = await new Response(p.stdout).text();
+ await p.exited.finally(() => clearTimeout(timer));
+ let info = null;
+ try { info = readProbe(JSON.parse(out)); } catch {}
+ if (!info || info.dur > HLS_MAX_SECONDS) { failed.set(key, Date.now()); return null; }
+ info = { src, ...info, rungs: rungsFor(info.w, info.h) };
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(file, JSON.stringify(info));
+ return info;
+ })().finally(() => probing.delete(key)));
+ return probing.get(key);
+}
+const segCount = (info) => Math.max(1, Math.ceil(info.dur / SEG - 0.01));
+export function masterPlaylist(info, key) {
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-INDEPENDENT-SEGMENTS'];
+ for (const r of info.rungs) {
+ const bw = Math.round((r.vb * 1.1 + (info.audio ? r.ab : 0)) * 1000);
+ lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bw},RESOLUTION=${r.w}x${r.h},CODECS="avc1.640028${info.audio ? ',mp4a.40.2' : ''}"`, `/vmedia/hls/${key}/${r.name}.m3u8`);
+ }
+ return lines.join('\n') + '\n';
+}
+// the whole clip's segments, known from its length before any is made
+export function mediaPlaylist(info, rung) {
+ const n = segCount(info);
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:3', `#EXT-X-TARGETDURATION:${SEG}`, '#EXT-X-PLAYLIST-TYPE:VOD', '#EXT-X-MEDIA-SEQUENCE:0'];
+ for (let i = 0; i < n; i++) {
+ const d = i < n - 1 ? SEG : Math.max(0.1, info.dur - SEG * (n - 1));
+ lines.push(`#EXTINF:${d.toFixed(3)},`, `${rung}/${String(i).padStart(5, '0')}.ts`);
+ }
+ lines.push('#EXT-X-ENDLIST');
+ return lines.join('\n') + '\n';
+}
+// One rendition from segment `from` on. The keyframes fall every SEG
+// seconds of the clip, so a segment made by a restarted encoder is the one
+// the first would have made (its times shifted by where it started).
+export const hlsArgs = (src, info, r, from, dir) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET,
+ '-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_delay_max', '5',
+ ...(from ? ['-ss', String(from * SEG)] : []), '-i', src,
+ '-map', '0:v:0', ...(info.audio ? ['-map', '0:a:0'] : []),
+ '-vf', `scale=${r.w}:${r.h}`, '-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'high', '-pix_fmt', 'yuv420p',
+ '-b:v', r.vb + 'k', '-maxrate', Math.round(r.vb * 1.2) + 'k', '-bufsize', r.vb * 2 + 'k', '-threads', '2',
+ '-force_key_frames', `expr:gte(t,n_forced*${SEG})`,
+ ...(info.audio ? ['-c:a', 'aac', '-b:a', r.ab + 'k', '-ac', '2'] : []),
+ ...(from ? ['-output_ts_offset', String(from * SEG)] : []),
+ '-f', 'hls', '-hls_time', String(SEG), '-hls_list_size', '0', '-hls_segment_type', 'mpegts', '-hls_flags', 'temp_file',
+ '-start_number', String(from), '-hls_segment_filename', `${dir}/%05d.ts`, `${dir}/enc.m3u8`];
+const encoders = new Map(); // key/rung -> { p, from, next, last, at, paused, failed }
+const segPath = (dir, n) => `${dir}/${String(n).padStart(5, '0')}.ts`;
+function stopEncoder(id) {
+ const e = encoders.get(id);
+ if (!e) return;
+ encoders.delete(id);
+ try { if (e.paused) e.p.kill('SIGCONT'); e.p.kill(9); } catch {}
+}
+function startEncoder(id, src, info, r, from, dir) {
+ stopEncoder(id);
+ if (encoders.size >= HLS_ENCODERS) {
+ // the one asked of longest ago makes way, if it isn't in use
+ const [oldId, old] = [...encoders].sort((a, b) => a[1].at - b[1].at)[0];
+ if (Date.now() - old.at < 10_000) return null;
+ stopEncoder(oldId);
+ }
+ mkdirSync(dir, { recursive: true });
+ const p = Bun.spawn(['ffmpeg', ...hlsArgs(src, info, r, from, dir)], { stdout: 'ignore', stderr: 'pipe' });
+ const e = { p, from, next: from, last: from, at: Date.now(), paused: false, done: false, ok: false };
+ encoders.set(id, e);
+ p.exited.then(async (code) => {
+ e.done = true; e.ok = code === 0;
+ if (code !== 0 && code !== null && encoders.get(id) === e) console.log('hls encode failed', src, r.name, from, (await new Response(p.stderr).text()).slice(-300));
+ if (encoders.get(id) === e) encoders.delete(id);
+ });
+ return e;
+}
+// how far each encoder has got; ahead of its player it waits, idle it stops
+function tendEncoders() {
+ for (const [id, e] of encoders) {
+ const dir = `${HLS}/${id}`;
+ while (existsSync(segPath(dir, e.next))) e.next++;
+ if (Date.now() - e.at > IDLE_MS) { stopEncoder(id); continue; }
+ try {
+ if (!e.paused && e.next - e.last > AHEAD) { e.p.kill('SIGSTOP'); e.paused = true; }
+ else if (e.paused && e.next - e.last <= AHEAD - 3) { e.p.kill('SIGCONT'); e.paused = false; }
+ } catch {}
+ }
+}
+async function segResponse(req, key, rungName, n) {
+ const dir = `${HLS}/${key}`;
+ let info = null;
+ try { info = JSON.parse(readFileSync(`${dir}/info.json`, 'utf8')); } catch {}
+ const r = info && info.rungs.find((x) => x.name === rungName);
+ if (!r || n >= segCount(info)) return nope(404, 'No segment');
+ touch(dir);
+ const id = `${key}/${rungName}`, rdir = `${dir}/${rungName}`, file = segPath(rdir, n);
+ let e = encoders.get(id);
+ if (e) { e.at = Date.now(); e.last = Math.max(e.from, n); }
+ if (existsSync(file)) return fileResponse(req, file, 'video/mp2t');
+ // an encoder that will reach it soon, or one started there
+ if (e) while (existsSync(segPath(rdir, e.next))) e.next++;
+ if (!e || e.done || n < e.from || n > e.next + 2) e = startEncoder(id, info.src, info, r, n, rdir);
+ if (!e) return nope(503, 'Busy');
+ e.last = n;
+ tendEncoders();
+ const t0 = Date.now();
+ while (!existsSync(file) && Date.now() - t0 < 45_000) {
+ if (e.done) break;
+ await Bun.sleep(100);
+ }
+ return existsSync(file) ? fileResponse(req, file, 'video/mp2t') : nope(e.done ? 404 : 503, 'Not made');
+}
+const M3U8 = 'application/vnd.apple.mpegurl';
+const playlist = (text) => new Response(text, { headers: { 'Content-Type': M3U8, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' } });
+async function hlsResponse(req, route, u, ip) {
+ if (route === '/hls.m3u8') {
+ const src = await sourceOk(u.searchParams.get('u') || '');
+ if (!src) return nope(400, 'Not a public https URL');
+ const key = hlsKey(src);
+ if (!existsSync(`${HLS}/${key}/info.json`) && !failedLately(key) && !probing.has(key) && !allowed(ip, hlsAllowance, HLS_PER_HOUR)) return nope(429, 'Too many');
+ const info = await hlsInfo(src, key);
+ return info ? playlist(masterPlaylist(info, key)) : nope(404, 'Not playable');
+ }
+ const m = /^\/hls\/([0-9a-f]{64})\/(\w+)(?:\.m3u8|\/(\d{5})\.ts)$/.exec(route);
+ if (!m) return nope(404, 'Not found');
+ if (m[3] != null) return segResponse(req, m[1], m[2], Number(m[3]));
+ let info = null;
+ try { info = JSON.parse(readFileSync(`${HLS}/${m[1]}/info.json`, 'utf8')); } catch {}
+ if (!info || !info.rungs.some((r) => r.name === m[2])) return nope(404, 'Not found');
+ return playlist(mediaPlaylist(info, m[2]));
+}
+
// ---- responses --------------------------------------------------------------
const WEEK = 'public, max-age=604800, immutable';
function fileResponse(req, path, type) {
@@ -337,10 +538,21 @@ function trim(dir, cap) {
for (const f of files) { if (total <= cap) break; try { unlinkSync(f.p); total -= f.size; } catch {} }
} catch {}
}
+// the HLS clips go whole, the one asked of longest ago first (not one being made)
+function trimHls(cap) {
+ try {
+ const size = (p) => { let n = 0; for (const f of readdirSync(p, { withFileTypes: true })) n += f.isDirectory() ? size(p + '/' + f.name) : sizeOf(p + '/' + f.name); return n; };
+ const busy = new Set([...encoders.keys()].map((id) => id.split('/')[0]));
+ const dirs = readdirSync(HLS).filter((k) => !busy.has(k)).map((k) => ({ k, at: statSync(HLS + '/' + k).mtimeMs, size: size(HLS + '/' + k) })).sort((a, b) => a.at - b.at);
+ let total = dirs.reduce((n, d) => n + d.size, 0);
+ for (const d of dirs) { if (total <= cap) break; try { rmSync(HLS + '/' + d.k, { recursive: true, force: true }); total -= d.size; } catch {} }
+ } catch {}
+}
export function startTrim() {
- const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); };
+ const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); trimHls(HLS_CAP); };
go();
setInterval(go, 10 * 60_000);
+ setInterval(tendEncoders, 1000);
// work left over from a restart is no good to anyone
for (const f of readdirSync(WORK)) try { unlinkSync(WORK + '/' + f); } catch {}
}
@@ -349,16 +561,17 @@ export async function handle(req) {
const u = new URL(req.url);
if (!['GET', 'HEAD'].includes(req.method)) return nope(405, 'GET only');
const route = u.pathname.replace(/^\/vmedia/, '');
- if (route !== '/thumb.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found');
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown';
+ if (route.startsWith('/hls')) return hlsResponse(req, route, u, ip);
+ if (route !== '/thumb.jpg' && route !== '/first.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found');
const src = await sourceOk(u.searchParams.get('u') || '');
if (!src) return nope(400, 'Not a public https URL');
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown';
if (route === '/pic.webp') {
const shape = u.searchParams.get('s') || 'col';
return SHAPES[shape] ? picResponse(req, src, shape, ip) : nope(400, 'Unknown shape');
}
- if (route === '/thumb.jpg') {
- const p = await thumb(src);
+ if (route === '/thumb.jpg' || route === '/first.jpg') {
+ const p = await (route === '/first.jpg' ? first(src) : thumb(src));
return p ? fileResponse(req, p, 'image/jpeg') : nope(404, 'No picture');
}
return clipResponse(req, src, ip);