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.js100
-rw-r--r--src/ark/manager.js14
-rw-r--r--src/features/ark.js6
-rw-r--r--src/features/messages.js1349
-rw-r--r--src/features/nwc.js94
-rw-r--r--src/features/zaps.js138
-rw-r--r--src/feed-cache.js2
-rw-r--r--src/i18n.js60
-rw-r--r--src/lnurl.js79
-rw-r--r--src/marmot-client.js74
-rw-r--r--src/negentropy.js197
-rw-r--r--src/nostr.js94
-rw-r--r--src/nwc-bg.js12
-rw-r--r--src/nwc-budget.js54
-rw-r--r--src/nwc-respond.js85
-rw-r--r--src/style.css63
-rw-r--r--src/sw-nwc.js11
-rw-r--r--src/thread-prefetch.js8
-rw-r--r--tools/ark/bg-renewal-test.js135
-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.js9
-rw-r--r--tools/feed-picture-pager-test.js7
-rw-r--r--tools/feed-wheel-reverse-test.js87
-rw-r--r--tools/lnurl-auth-test.js102
-rw-r--r--tools/lnurl-auth-unit-test.js63
-rw-r--r--tools/lnurl-withdraw-test.js103
-rw-r--r--tools/negentropy-sync-test.js159
-rw-r--r--tools/negentropy-test.js64
-rw-r--r--tools/nwc-budget-test.js52
-rw-r--r--tools/nwc-settings-ui-test.js80
-rw-r--r--tools/nwc-sw-test.js131
-rw-r--r--tools/nwc-test.js66
-rw-r--r--tools/pager-caption-test.js134
-rw-r--r--tools/pager-controls-test.js114
-rw-r--r--tools/pager-first-frame-test.js124
-rw-r--r--tools/pager-gone-picture-test.js72
-rw-r--r--tools/pager-loading-test.js82
-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-sharpen-test.js78
-rw-r--r--tools/pager-swipe-fly-test.js181
-rw-r--r--tools/pager-tap-slop-test.js1
-rw-r--r--tools/pager-wheel-test.js2
-rw-r--r--tools/picture-pager-ready-test.js102
-rw-r--r--tools/profile-pager-test.js5
-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-pager-test.js134
-rw-r--r--tools/thread-prefetch-test.js10
-rw-r--r--tools/wn-only-device-test.js129
-rw-r--r--vmedia/server.js32
72 files changed, 5211 insertions, 3909 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 7f6baa3..f904cc3 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6617,13 +6617,25 @@ if (INTENT_URI) {
// selection, and a drag that moved isn't a click on what it started on.
(() => {
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,48 +6665,66 @@ if (INTENT_URI) {
if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); }
};
// ---- the wheel
- // A turn back against the coast is a reader who flew past the post they
- // wanted and is hunting for it: the page is caught at once, and that notch
- // and the next few are short, quick steps (a quarter of a notch's travel,
- // settling fast) rather than a fresh flight the other way. Each notch on
- // in the same direction grows the step back towards a full one, so a few
- // flicks have the wheel flying again; a turn back while hunting starts the
- // small steps over, and a pause ends the hunt — the next notch is the
- // plain push from rest it has always been.
- const HUNT = [0.25, 0.35, 0.5, 0.7, 0.85]; // of a notch's travel, notch by notch
- const HUNT_TAU = 110; // ms: a hunting step settles quickly
- const HUNT_IDLE = 2000; // ms without a notch, and the hunt is over
- let hunt = -1, huntDir = 0, huntAt = 0; // the next HUNT entry while hunting
- const halt = () => { stop(); hunt = -1; };
+ // 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;
const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX);
- if (!notch) { smoothUntil = e.timeStamp + 400; halt(); return; }
+ if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; }
if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return;
e.preventDefault();
const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
const dir = Math.sign(px);
if (!dir) return;
const now = e.timeStamp;
- if (hunt >= 0 && now - huntAt > HUNT_IDLE) hunt = -1;
- // the way the page is going: its coast, the coast that just died, or the hunt
- const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : hunt >= 0 ? huntDir : 0;
- if (going && dir !== going) { stop(); hunt = 0; huntDir = dir; }
- else if (hunt >= 0 && ++hunt >= HUNT.length) hunt = -1;
- if (hunt >= 0) {
- // 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 a quick coast over the rest of its
- // (short) travel; quick flicks still add up.
- huntAt = now;
- const travel = px * 3 * HUNT[hunt];
- const first = dir * Math.min(16, Math.abs(travel / HUNT_TAU) * 16);
+ // 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;
+ 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();
+ 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(v + (travel - first) / HUNT_TAU, HUNT_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
@@ -6704,12 +6734,12 @@ if (INTENT_URI) {
}
}, { 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)) halt(); });
- window.addEventListener('touchstart', halt, { passive: true });
+ 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) halt(); // a click catches a coasting page, like a finger does
+ 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: [] };
});
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 703d341..d5ae2b0 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -171,6 +171,12 @@ export function pruneArkState(state, now = Date.now()) {
return state;
}
+// How close to expiry the server-side backstop renewal is aimed: well inside
+// the free fee bracket (under 96 blocks of life left on our ASPs), and far
+// enough out — 36 blocks, six hourly rounds — for the round to run in time.
+// Shared by the app (features/ark.js) and the worker tending a closed wallet.
+export const backstopMargin = (network) => (network === 'regtest' ? 6 : 36);
+
export class ArkManager {
constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) {
this.account = account; // HDKey node; ark keys derived beneath it
@@ -2419,6 +2425,14 @@ export class ArkManager {
scheduledRefreshes() { return (this.state.scheduled || []).slice(); }
+ // Union of two devices' standing orders (the app's and the worker's, or
+ // two devices'), keyed by unlock hash; dead ones are dropped on the next
+ // _driveScheduled, so an order for a coin the other side spent is harmless.
+ static mergeScheduled(a = [], b = []) {
+ const seen = new Set(a.map((s) => s.unlockHash));
+ return [...a, ...b.filter((s) => s.unlockHash && !seen.has(s.unlockHash))];
+ }
+
// Keep every coin covered by a standing order, `marginBlocks` before its
// expiry. Coins arrive, get spent, get renewed — each new one needs its own
// backstop while it still has the lifetime to register one. Caller owns the
diff --git a/src/features/ark.js b/src/features/ark.js
index 13481da..cbfd1b9 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -6,7 +6,7 @@ import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, backstopMargin } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -343,6 +343,8 @@ export function mergeArkStates(a, b) {
out.remoteRenewals = mergeRemoteRenewals(a, b);
delete out.renewals; // a snapshot's own list, adopted above as remote
out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1);
+ // standing renewal orders registered on the other side (the worker's, say)
+ out.scheduled = ArkManager.mergeScheduled(a.scheduled, b.scheduled);
out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0);
out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0);
out.serverPubkey = a.serverPubkey || b.serverPubkey || null;
@@ -3194,7 +3196,7 @@ export function arkFeature(ctx) {
// app that IS open still renews first (and the untouched backstop is
// dropped), and far enough from expiry — 36 blocks, six hourly rounds — for
// the round to actually run in time. Both are in the free fee bracket.
- const backstopWindow = () => (getNetwork() === 'regtest' ? 6 : 36);
+ const backstopWindow = () => backstopMargin(getNetwork());
let arkAutoRefreshAt = 0;
let arkBackstopAt = 0;
// Set when the balance is too small to renew itself (total minus round fee
diff --git a/src/features/messages.js b/src/features/messages.js
index c568ae0..ada978a 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,
@@ -169,6 +170,12 @@ export function messagesFeature(ctx) {
if (now) write(); else emojiSaveTimer = setTimeout(write, 500);
};
// code → image url: held packs first (in order), then what's been seen
+ // White Noise's own two emoji, which it sends as bare :codes: (no NIP-30
+ // tag, no picture): drawn from its published artwork, through our picture
+ // proxy — a copy served and cached here, as any picture in a post is, not
+ // bundled (the app is AGPL, this one MIT).
+ const WN_RAW = 'https://raw.githubusercontent.com/marmot-protocol/whitenoise-android/master/app/src/main/res/drawable-nodpi/';
+ const BUILTIN_EMOJI = { marmot: WN_RAW + 'builtin_emoji_marmot.png', wn: WN_RAW + 'builtin_emoji_wn.png' };
const emojiUrl = (code) => {
const s = emojiSt();
for (const addr of s.order) {
@@ -176,7 +183,7 @@ export function messagesFeature(ctx) {
if (!p) continue;
for (const [c, u] of p.emojis) if (c === code) return u;
}
- return s.learned[code] || null;
+ return s.learned[code] || (BUILTIN_EMOJI[code] ? serverPic(BUILTIN_EMOJI[code], 'tile') : null);
};
// everything typeable: [{ code, url, pack }], packs first, newest-seen next
function customEmojis() {
@@ -456,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);
};
@@ -682,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
@@ -706,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
@@ -3049,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);
@@ -3095,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();
@@ -3154,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
@@ -3416,6 +3493,7 @@ export function messagesFeature(ctx) {
function wnIntoThread(g, peer, m) {
const th = threadOf(peer);
const target = (m.tags.find((x) => x[0] === 'e') || [])[1];
+ if (m.kind === 7 || m.kind === 9) noteEmoji(m); // a :code: with its picture (NIP-30 tag): learned, so the chip draws it
if (m.kind === 9) {
if (!th.has(m.id) || th.get(m.id).pending) th.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id });
} else if (m.kind === 7 && target) {
@@ -3587,6 +3665,7 @@ export function messagesFeature(ctx) {
for (const m of sorted) if (m.kind === 9 || m.kind === 1210) byId.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id });
for (const m of sorted) {
const target = (m.tags.find((x) => x[0] === 'e') || [])[1];
+ if (m.kind === 7 || m.kind === 9) noteEmoji(m); // (a group's :marmot: came as text: its tag was never read)
if (m.kind === 7 && target) {
(reacts.get(target) || reacts.set(target, new Map()).get(target)).set(m.pubkey, m.content);
reactOf.set(m.id, { target, pk: m.pubkey });
@@ -4004,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.
@@ -4373,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();
}
@@ -4435,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();
@@ -4700,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) {
@@ -4767,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(() => {
@@ -4779,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 });
@@ -5169,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
@@ -5225,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;
@@ -5837,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;
@@ -5854,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);
}
@@ -5905,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); }
@@ -5928,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 {}
}
}
@@ -6040,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;
@@ -6063,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) {
@@ -6677,12 +6851,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, ''));
@@ -6700,7 +6883,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.
@@ -6709,6 +6892,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) {
@@ -6718,7 +6904,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;
}
@@ -6845,10 +7031,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);
@@ -6863,7 +7049,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();
@@ -6874,7 +7060,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
@@ -6937,7 +7123,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;
@@ -7124,23 +7310,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 }) => {
@@ -7153,7 +7341,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);
@@ -7354,22 +7542,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 {} }
@@ -7383,43 +7572,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();
@@ -7543,6 +7787,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)
@@ -7940,7 +8187,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;
@@ -9579,6 +9830,43 @@ export function messagesFeature(ctx) {
}
// Settings → Nostr: whether clips over the budget play by themselves.
+ // Settings → Nostr: which device of yours gets group invitations. A Marmot
+ // inviter (White Noise) picks one of your published keys; the group lands
+ // on that device alone, and nothing told you which (Adam's invitation sat
+ // on the phone while he looked on the laptop).
+ function groupDevicesCard() {
+ const pk = identityPk();
+ let d = ui.wnDevices;
+ if (!d || d.pk !== pk) {
+ d = ui.wnDevices = { pk, loading: true };
+ whiteNoise().then((c) => (c ? c.devices() : null)).then((r) => {
+ if (!ui.wnDevices || ui.wnDevices.pk !== pk) return;
+ ui.wnDevices = r ? { pk, ...r } : { pk, none: true };
+ render();
+ }).catch(() => { if (ui.wnDevices && ui.wnDevices.pk === pk) { ui.wnDevices = { pk, none: true }; render(); } });
+ }
+ const busy = !!ui.wnOnlyHere;
+ const state = d.loading ? null : d.none ? t('wnDeviceNone') : d.retired ? t('wnDeviceRetired') : d.published ? t('wnDeviceHere') : t('wnDeviceNone');
+ return h('div', { class: 'card col wn-devices-card', style: 'gap:8px' },
+ h('h3', {}, t('wnDevicesTitle')),
+ h('div', { class: 'small faint' }, t('wnDevicesHelp')),
+ state ? h('div', { class: 'small wn-device-state' }, state) : h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' })),
+ !d.loading && d.others ? (d.others.length
+ ? h('div', { class: 'col small', style: 'gap:2px' }, h('div', {}, t('wnDevicesOthers', { n: d.others.length })),
+ ...d.others.map((o) => h('div', { class: 'faint wn-device' }, (o.client || t('wnDeviceUnknown')) + ' \u00b7 ' + timeLabel(o.created_at * 1000))))
+ : h('div', { class: 'small faint wn-no-others' }, t('wnDevicesNoOthers'))) : null,
+ h('button', {
+ class: 'btn-sm wn-only-here', type: 'button', disabled: busy || d.loading || undefined,
+ onClick: () => {
+ ui.wnOnlyHere = true; render();
+ whiteNoise().then((c) => (c ? c.onlyHere({ interactive: true }) : Promise.reject(new Error('no client'))))
+ .then((n) => { toast(t('wnOnlyHereDone', { n })); ui.wnDevices = null; })
+ .catch(() => toast(t('wnOnlyHereFail')))
+ .finally(() => { ui.wnOnlyHere = false; render(); });
+ },
+ }, t('wnOnlyHere')));
+ }
+
function videosCard() {
const on = largeVideosOk();
return h('div', { class: 'card col videos-card', style: 'gap:8px' },
@@ -9888,22 +10176,28 @@ export function messagesFeature(ctx) {
if (!ui.reactPick) return null;
const ev = ui.reactPick;
const close = () => { ui.reactPick = null; render(); };
+ // over the media pager (a reaction from a slide): above it, and its
+ // like button hears the pick
+ const after = () => { if (pager && pager.cur) pagerCounts(pager.cur); };
return h('div', {
- class: 'confirm-pop-backdrop',
+ class: 'confirm-pop-backdrop' + (pager ? ' over-pager' : ''),
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
- reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}); }),
+ reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}).finally(after); setTimeout(after, 50); }),
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)
@@ -9928,6 +10222,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
@@ -9967,6 +10262,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', {
@@ -9976,7 +10274,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
@@ -10090,6 +10388,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;
@@ -10123,9 +10422,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;
@@ -10141,16 +10446,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.
@@ -10329,9 +10652,41 @@ export function messagesFeature(ctx) {
return evt;
}
+ // The thread's replies by parent, each note's answers in the order the
+ // page draws them (yours first, then oldest first); an orphan hangs off
+ // the root.
+ function threadTree(c) {
+ const parentOf = (ev) => {
+ // a comment's parent is its lowercase e; one answering the root by
+ // address (an article) has only an a — the root
+ if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
+ const es = ev.tags.filter((x) => x[0] === 'e');
+ const marked = es.find((x) => x[3] === 'reply');
+ return (marked || es[es.length - 1] || [])[1] || c.rootId;
+ };
+ const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
+ const children = new Map();
+ for (const ev of c.replies) {
+ const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId;
+ if (!children.has(pid)) children.set(pid, []);
+ children.get(pid).push(ev);
+ }
+ for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ return children;
+ }
+ // The thread's posts in the order the page shows them: the root, then
+ // the replies down the tree (a hidden one skipped, its answers kept).
+ function threadOrder(c) {
+ const children = threadTree(c), out = [];
+ if (c.root) out.push(c.root);
+ const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } };
+ walk(c.rootId);
+ return out;
+ }
function threadScreen() {
const s = ui.noteThread;
const c = threadFor(s.seed);
+ queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures
// the conversation on screen is the best count of its own replies
for (const e of c.replies) noteReply(e);
const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id));
@@ -10470,23 +10825,9 @@ export function messagesFeature(ctx) {
// under it, a little indented, oldest first — except your own, which go
// first among a note's answers, so what you just wrote shows right
// under the post instead of at the bottom of a long thread.
- const parentOf = (ev) => {
- // a comment's parent is its lowercase e; one answering the root by
- // address (an article) has only an a — the root
- if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId;
- const es = ev.tags.filter((x) => x[0] === 'e');
- const marked = es.find((x) => x[3] === 'reply');
- return (marked || es[es.length - 1] || [])[1] || c.rootId;
- };
- const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]);
- const children = new Map();
- for (const ev of c.replies) {
- const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; // an orphan hangs off the root
- if (!children.has(pid)) children.set(pid, []);
- children.get(pid).push(ev);
- }
+ const children = threadTree(c);
const walk = (pid, depth) => {
- const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
+ const list = children.get(pid) || [];
for (const ev of list) {
if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place
const indent = Math.min(depth, 3) * 14;
@@ -10496,8 +10837,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
@@ -11023,7 +11366,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' }))
@@ -11399,7 +11742,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) => {
@@ -11424,7 +11770,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) {
@@ -11435,17 +11781,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;
}
@@ -11454,7 +11796,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
@@ -11463,7 +11805,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);
@@ -11473,17 +11815,20 @@ export function messagesFeature(ctx) {
// time, no waiting on avatars, pictures and link cards (the grid shows
// none of that), and the feed's own page of posts left as it was.
let mediaCrawling = null;
- function crawlMedia(c = feedNow()) {
+ function crawlMedia(c = feedNow(), kind = mediaMode === 'videos' ? 'videos' : 'images') {
const def = feedDef(c.id);
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();
// Clips are rare (one post in a hundred, on a busy relay), so a crawl
// keeps reading pages until it has found a few, or read several.
- const kind = mediaMode === 'videos' ? 'videos' : 'images';
const had = r[kind].size;
for (let pass = 0; pass < 5 && r[kind].size - had < 6; pass++) {
const from = [r.until, c.notes.at(-1)?.created_at].filter(Boolean);
@@ -11495,9 +11840,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; }
@@ -11617,7 +11966,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);
@@ -11729,6 +12078,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();
@@ -11768,11 +12179,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;
}
@@ -11795,7 +12237,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 };
@@ -11846,11 +12288,22 @@ export function messagesFeature(ctx) {
const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList;
return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
}
- // the page the row is on: the feed, or a person's timeline (their
- // profile page) — the pager runs over that page's pictures (or clips)
+ // the page the row is on: the feed, a person's timeline (their profile
+ // page) or a thread — the pager runs over that page's pictures (or clips)
const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread);
+ const onThreadPage = () => !!ui.noteThread && !onProfilePage();
+ // the thread on screen, by its root — the key a thread pager is tied to
+ const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null;
+ // a thread as a list the pager can read (feedMediaItems), in the page's order
+ const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 });
function pageRowOf(el) {
- const rowId = el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null;
+ if (!el || !el.closest) return null;
+ if (onThreadPage()) {
+ const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null;
+ const tid = threadPageId();
+ return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null;
+ }
+ const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null;
if (!rowId) return null;
if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; }
return feedRowOf(el) ? { c: feed, rowId, profile: null } : null;
@@ -11860,6 +12313,13 @@ export function messagesFeature(ctx) {
// as the reader nears the end. The media grid's pager has no source here.
function pagerSource(p = pager) {
if (!p) return null;
+ if (p.thread) {
+ // a thread's pictures: the root's and its replies', as the page lists
+ // them — all here already, nothing more to fetch
+ const c = threadCache.get(p.thread);
+ return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true,
+ live: () => threadPageId() === p.thread } : null;
+ }
if (p.profile) {
const c = notesCache.get(p.profile);
return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore,
@@ -11870,14 +12330,17 @@ export function messagesFeature(ctx) {
}
// A picture tapped in the feed opens in the media pager, on that picture,
// with the feed's other pictures a swipe (or an arrow) away, the way the
- // media grid's are. On any other page — a thread, a profile, a chat — the
- // picture opens alone, as before.
+ // media grid's are; on a profile page over that person's pictures, and on
+ // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever
+ // a picture is opened from — a thread's opened alone and a tap on it
+ // closed it, with no caption or controls). Elsewhere — a chat, a face —
+ // the picture opens alone.
function openPicture(url, e) {
try {
const at = pageRowOf(e && e.target);
if (at) {
- const it = feedMediaItems(at.c, 'images').find((x) => x.row.id === at.rowId && x.url === url);
- if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile, profile: at.profile }); return; }
+ const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url);
+ if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; }
}
} catch {}
ctx.openImage && ctx.openImage(url);
@@ -11891,12 +12354,12 @@ export function messagesFeature(ctx) {
try {
const on = pageRowOf(v);
if (!on) return false;
- const it = feedMediaItems(on.c, 'videos').find((x) => x.row.id === on.rowId && x.url === url);
+ const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url);
if (!it) return false;
tappedClips.add(url); // asked for by hand: no tap-to-play cover
const at = v.currentTime || 0;
v.pause();
- openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile, profile: on.profile, at });
+ openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at });
pagerFullscreen();
return true;
} catch { return false; }
@@ -11927,15 +12390,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 {}
}
@@ -11945,7 +12431,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);
@@ -12054,13 +12540,13 @@ export function messagesFeature(ctx) {
// `fromFeed`: opened from a picture or clip in the feed (its pictures or
// clips, in the feed's order), rather than from the media grid; `at`: where
// the clip it opened on was, so it carries on from there
- function openMediaPager(kind, start, { fromFeed = false, profile = null, at = 0 } = {}) {
+ function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) {
// the page's own players stop: the pager's is the one to hear
for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} }
for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) {
try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {}
}
- ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, at };
+ ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at };
render();
}
const closeMediaPager = () => ctx.closeLayer('mediaPager');
@@ -12079,6 +12565,15 @@ export function messagesFeature(ctx) {
// the pop that closes it lands on a later turn: the glide waits for the feed
let tries = 0;
const go = () => {
+ if (src.thread) {
+ // a thread's post is drawn already (no window): just bring it on screen if it isn't
+ if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; }
+ if (!src.live()) return;
+ const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]');
+ const r = el && el.getBoundingClientRect();
+ if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8));
+ return;
+ }
if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; }
landOnPost(src, rowId);
};
@@ -12132,10 +12627,11 @@ export function messagesFeature(ctx) {
let pagerLoud = true; // sound on, unless the browser said no without a tap
function syncMediaPager() {
const m = ui.mediaPager;
- const want = m && (m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
+ const want = m && (m.thread ? threadPageId() === m.thread
+ : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile)
: ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null;
if (!want) { if (pager) dropPager(); return; }
- if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null))) dropPager();
+ if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager();
if (!pager) buildPager(want);
else growPager();
}
@@ -12158,8 +12654,10 @@ export function messagesFeature(ctx) {
const zp = pendingOf(ev.id);
const sats = s.sats + (zp ? zp.sats : 0);
const like = slide.querySelector('.mp-like'), zap = slide.querySelector('.mp-zap');
- like.classList.toggle('on', !!myReactOn(ev.id));
- like.querySelector('.i').innerHTML = I_HEART(!!myReactOn(ev.id));
+ const mine = myReactOn(ev.id);
+ like.classList.toggle('on', !!mine);
+ if (mine) like.querySelector('.i').replaceChildren(postReactNode(mine)); // the emoji chosen, a picture for a :code:
+ else like.querySelector('.i').innerHTML = I_HEART(false);
like.querySelector('.n').textContent = s.likes ? String(s.likes) : '';
zap.classList.toggle('on', sats > 0);
const replies = replyCount(ev.id);
@@ -12169,11 +12667,40 @@ export function messagesFeature(ctx) {
function pagerSlide(it) {
const { ev, url } = it;
const video = pager.kind === 'videos';
+ // A picture flies in as the feed's copy of it (1100px wide, decoded
+ // already if the feed painted it; else the server's, cached at the CDN)
+ // and is sharpened to the original once it comes to rest (pagerSharpen).
+ // Originals as they passed — camera files, several megapixels each —
+ // decoded on the main thread and were uploaded as textures mid-flight,
+ // 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) })
- : h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' });
- const caption = mediaCaption(ev);
+ 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;
+ if ((m.getAttribute('src') || '') !== url) { m.setAttribute('data-src', url); m.setAttribute('src', url); return; }
+ // the original is gone too (its host down, the file deleted): say
+ // so, in place of the browser's broken-picture glyph
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ 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, true);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
media,
@@ -12181,12 +12708,14 @@ 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
h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => {
if (signinAsk()) { leavePager(); render(); return; }
- (myReactOn(ev.id) ? unreact(ev) : reactTo(ev, '❤️')).catch(() => {}).finally(() => pagerCounts(slide));
- setTimeout(() => pagerCounts(slide), 50);
+ if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
+ ui.reactPick = ev; render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
@@ -12266,7 +12795,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
@@ -12280,19 +12815,39 @@ 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) {
const m = slide.querySelector('.mp-media');
if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src'));
}
+ // The slide at rest gets its picture's original — fetched and decoded
+ // aside first, then swapped in (same box, same aspect: nothing moves) —
+ // a moment after landing, so a quick page past it costs nothing.
+ function pagerSharpen(slide) {
+ const m = slide && slide.querySelector('img.mp-media');
+ const full = m && m.getAttribute('data-full');
+ if (!full || m.getAttribute('src') === full || m._sharp) return;
+ m._sharp = setTimeout(() => {
+ m._sharp = 0;
+ if (!pager || pager.cur !== slide) return;
+ const im = new Image();
+ im.decoding = 'async';
+ im.onload = () => { const go = () => { if (pager && m.isConnected && m.getAttribute('src') !== full) { m.setAttribute('data-src', full); m.setAttribute('src', full); } }; (im.decode ? im.decode().then(go, go) : go()); };
+ im.src = full;
+ }, 300);
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
@@ -12318,6 +12873,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) {
@@ -12336,6 +12893,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.
@@ -12368,14 +12975,14 @@ 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);
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;
@@ -12387,7 +12994,10 @@ 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;
}
function playSlide(slide) {
const v = slide.querySelector('video.mp-media');
@@ -12402,11 +13012,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) || {};
@@ -12428,8 +13038,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);
@@ -12473,7 +13085,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';
@@ -12486,7 +13098,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();
};
@@ -12539,6 +13151,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();
@@ -12553,21 +13174,45 @@ export function messagesFeature(ctx) {
const here = pagerHere();
from = here && here.closest('.mp-group');
}
- const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2);
+ const next = stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2);
const i = (next % stops.length + stops.length) % stops.length;
if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
const to = stops[i];
fly = { to, n, at: now, dir };
if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
+ pagerFlyTo(to);
+ }
+ // The flight itself: a glide to the post, the pictures passing, landing
+ // snapped on it — only the landing is arrived at. A flick of a finger
+ // (pace: a little slower per screen than the wheel's) comes here too.
+ function pagerFlyTo(to, { vel = 0, pace = 110, most = 1200 } = {}) {
const dest = pagerAt(pager.scroller, to, true);
const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
pager.flying = true;
- pagerGlide(pager.scroller, 'y', dest, 0, {
- ms: screens > 1.05 ? Math.min(1100, 100 + 110 * screens) : 0,
- onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); },
+ 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); 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;
@@ -12586,24 +13231,44 @@ export function messagesFeature(ctx) {
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)
@@ -12658,13 +13323,32 @@ export function messagesFeature(ctx) {
// the browser sent no click): a tap meant to pause a clip drifts.
const FOLLOW = 10, TAP_SLOP = 24, TAP_MS = 350; // px; px; ms
const SWIPE_X = 0.1, SWIPE_Y = 0.12, FLICK = 0.2, FLICK_MIN = 24; // of the screen; px/ms; px
+ // A fast flick up or down flies past several posts, the way a spin of
+ // the wheel does, and lands snapped on one (Adam, 2026-10-16: "make
+ // images coast on the phone too when I swipe"): as many posts as the
+ // fling would have coasted — the speed times FLY_TAU, in screens — from
+ // FLY_V up; a slower swipe is the one post it was. The ends are not
+ // flown past: a fling that would, lands on the last (or first) post.
+ // (3 px/ms and a 500 ms coast took a hard flick to fly at all — Adam,
+ // 2026-10-16: "too hard to get it to fly"; then 800 ms and six posts:
+ // "make it fly longer before it settles down… I can slow it down by
+ // touching again when I see something I like".) So a long coast, taken
+ // slowly enough to watch and to catch: a finger down holds it.
+ const FLY_V = 1.6, FLY_TAU = 1600, FLY_MAX = 14; // px/ms; ms; posts
+ const TRACK_MS = 80; // the speed is the finger's over its last moment, not its last few samples
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;
@@ -12684,18 +13368,40 @@ 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)
- if (d.track.length > 5) d.track.shift();
+ // 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)
+ // 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 next = from + step;
- if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(next + stops.length) % stops.length]); return; }
+ 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;
+ const next = from + step * posts;
+ // Past either end, round to the other: the first post (going up, the
+ // last) — a step, or a fling whose coast is mostly beyond the end. A
+ // fling from near the end used to stop on the last post, and there,
+ // where the clips still warming join one by one, each flick landed on
+ // the newest instead of getting round (Adam, 2026-10-17: "make it
+ // coast right around to the start again even if a video near the end
+ // is loading in"). A fling from further back, only its tail past the
+ // end, flies to the last post.
+ const left = step > 0 ? stops.length - 1 - from : from; // posts between here and the end it heads for
+ if (y && (next < 0 || next >= stops.length) && left * 2 < posts) { const g = pagerGroups(); pagerWrap(next < 0 ? g[g.length - 1] : g[0]); return; }
+ 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; }
+ }
pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel);
}
el.addEventListener('touchend', (e) => {
@@ -12707,27 +13413,36 @@ 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(() => {
pendingTap = 0;
- tapDoneAt = Date.now();
const btn = d.target.closest && d.target.closest('button, a');
- if (btn) btn.click(); else pagerTap(d.target);
+ if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed)
+ tapDoneAt = Date.now();
}, 120);
}, { passive: true });
el.addEventListener('touchcancel', () => { if (touch && touch.box) settle(touch, 0); touch = null; }, { passive: true });
+ // The browser's click for a touch: it IS the tap, so the pending one
+ // stands down — judged on the way down (capture), before a button's own
+ // handler stops the bubble: the sound button's click never reached this
+ // listener, the pending tap clicked it again 120 ms later, and a clip
+ // could not be muted (Adam, 2026-10-16). A click after the pending tap
+ // has already been done is swallowed before it reaches anything.
el.addEventListener('click', (e) => {
- if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } // the browser's click came: it's the tap
- else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); return; } // done already
- pagerTap(e.target);
- });
+ if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; }
+ else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
+ }, true);
+ el.addEventListener('click', (e) => pagerTap(e.target));
// a tap shows (or hides) who posted it and what they said
function pagerTap(target) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
@@ -12744,11 +13459,12 @@ export function messagesFeature(ctx) {
return;
}
if (pager && pagerSource()) {
- // Opened from a post in the feed (or on a profile): a tap on the picture that opened
- // it closes it, as a full-screen picture always has. Once the reader
- // has moved on, the first tap shows the post's details, and the next
- // closes the pager onto that post in the feed.
- if (pagerOnStart()) { closeMediaPager(); return; }
+ // Opened from a post on a page (the feed, a profile, a thread): the
+ // first tap shows the post's details — its caption and controls —
+ // and the next closes the pager onto that post. (A tap on the
+ // picture that opened it used to close it outright, as a full-screen
+ // picture had; Adam, 2026-10-16: a tap should bring the details up
+ // wherever the picture was opened from.)
if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; }
dismissPager();
return;
@@ -12756,7 +13472,7 @@ export function messagesFeature(ctx) {
el.classList.toggle('show');
if (el.classList.contains('show')) pagerCounts(pager && pager.cur);
}
- pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, start: want.start, at: want.at || 0 };
+ pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 };
pager.near = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
@@ -12764,9 +13480,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;
@@ -12776,18 +13496,20 @@ export function messagesFeature(ctx) {
if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
if (pagerSource() && video) el.classList.remove('show');
}
+ if (pager.cur) pager.cur.classList.remove('mp-cur');
+ slide.classList.add('mp-cur'); // (the one with the layer for a pinch: style.css)
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
+ pagerSharpen(slide);
pagerArrows(slide);
pagerPosition();
if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); }
- else if (pager.profile) markSeen(slide._ev);
+ else if (pager.profile || pager.thread) markSeen(slide._ev);
playSlide(slide);
pagerCounts(slide);
- // near the end: ask the feed for more, the pager picks them up
- if (pagerSource()) pagerFeedAhead();
- else { const gs = pagerGroups(); if (gs.indexOf(slide.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); }
+ pagerLoadAhead();
+ pagerWarmPics();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -12805,10 +13527,26 @@ export function messagesFeature(ctx) {
if (first) pagerShow(first);
pager.anchor = first; // the slide meant to be on screen, from the first moment
pager.screenH = scroller.clientHeight;
- // the clip on screen's seek bar, every frame (timeupdate comes a few times a second)
- if (video) {
+ pagerLoadAhead();
+ // 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
@@ -12824,36 +13562,61 @@ 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
- pager.tick = setInterval(() => { if (pager) { growPager(); pagerFeedAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
+ pager.tick = setInterval(() => { if (pager) { growPager(); pagerLoadAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500);
}
- // A pager opened from the feed walks the feed's own posts, which page in
- // ten at a time as the feed is scrolled. Swiping is the scrolling here:
- // with only a few posts left below the one on screen (PAGER_AHEAD), the feed pages on
- // (the posts it holds, then older ones from the relays) until there are
- // more — several pages if those posts carry none. A page that brings
- // nothing (the feed busy, or at its end) stops it; the tick tries again.
- // how many posts ahead to keep: clips are fewer and further between
- // (more pages to find the next) and each is watched a while, so further
- const PAGER_AHEAD = { images: 4, videos: 6 };
- let pagerFeeding = false;
- async function pagerFeedAhead() {
- const src = pagerSource();
- if (pagerFeeding || !src) return;
- pagerFeeding = true;
+ // All media pagers build the same buffer, retaining the source's ordering
+ // and filters. The furthest post reached never moves back when we wrap,
+ // so pending refills continue while the reader circles through older items.
+ const PAGER_INITIAL = 30, PAGER_AHEAD = 20;
+ function pagerLoadSource(p) {
+ const src = pagerSource(p);
+ if (src) return {
+ more: src.more, done: src.done,
+ progress: () => src.c.shown + ':' + src.c.notes.length,
+ };
+ // A profile cache can be replaced while refreshing; never fall back
+ // to another source in that gap. The next timer tick will try again.
+ if (p.profile || p.fromFeed) return { more: async () => {}, done: () => true, progress: () => '' };
+ const c = feedNow(), r = mediaIndex(c.id);
+ const curated = feedDef(c.id)?.curated;
+ return {
+ more: () => crawlMedia(c, p.kind),
+ done: () => c.stopped || (curated ? c.end && c.shown >= c.notes.length : !!r.end),
+ progress: () => [r.until, r[p.kind].size, c.shown, c.notes.length].join(':'),
+ };
+ }
+ async function pagerLoadAhead() {
+ const p = pager;
+ if (!p) return;
+ 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;
+ p.loadingAhead = true;
+ let stalled = false;
try {
- const c = src.c;
- for (let pages = 0; pages < 8 && pager && pagerSource(); pages++) {
- const gs = pagerGroups();
- if (gs.length - 1 - gs.indexOf(pager.cur && pager.cur.closest('.mp-group')) > (PAGER_AHEAD[pager.kind] || 4)) break;
- const shown = c.shown, held = c.notes.length;
+ // Bound each run; the timer resumes if sparse media needs more pages.
+ for (let pages = 0; pages < 8 && pager === p; pages++) {
+ // (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();
await src.more();
+ if (pager !== p) return;
growPager();
- if (c.shown === shown && c.notes.length === held) break;
+ if (src.progress() === before) { stalled = true; break; }
}
- } catch {} finally { pagerFeeding = false; }
+ } catch { stalled = true; } finally {
+ p.loadingAhead = false;
+ // An empty or busy source gets a breather, never concurrent requests.
+ p.retryAheadAt = Date.now() + (stalled ? 10000 : 1500);
+ }
}
// Slides for feed items the pager doesn't have yet: into their post's row
// (a quote fetched late joins its post), a new post appended below (never
@@ -12872,16 +13635,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() {
@@ -12901,6 +13762,7 @@ export function messagesFeature(ctx) {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => {
if (!pager || e.defaultPrevented) return;
+ if (ui.reactPick) { if (e.key === 'Escape') { e.preventDefault(); ui.reactPick = null; ui.emojiPick = null; render(); } return; } // the emoji sheet's
if (e.key === 'Escape') { e.preventDefault(); dismissPager(); return; }
// up and down: the next post, or the one before; left and right: along this post's row
const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0;
@@ -12926,8 +13788,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);
@@ -12954,7 +13816,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 —
@@ -13024,7 +13886,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')))
@@ -13033,16 +13895,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,
@@ -13103,7 +13965,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,
@@ -13144,7 +14006,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();
@@ -14694,6 +15556,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
@@ -14753,6 +15637,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);
}
@@ -14865,9 +15750,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';
@@ -15068,7 +15955,7 @@ export function messagesFeature(ctx) {
// group-chat state is device state in IndexedDB: it goes with the account
wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); },
forgetAll() { wipeMediaIndex(); mediaIdx.clear(); try { caches.delete(VIDEO_THUMB_CACHE); } catch {} },
- nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard(), translateCard(), videosCard()]; },
+ nostrSettingsCards() { return [relaysCard(), followHistoryCard(), groupDevicesCard(), moderationCard(), translateCard(), videosCard()]; },
// The app came back after being backgrounded. A phone freezes a hidden
// tab: the relay sockets are cut and every post made in the meantime is
// simply missing, which is why the feed used to sit there looking stale
@@ -15419,7 +16306,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/features/nwc.js b/src/features/nwc.js
index 9cf8d0b..ec79081 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -34,13 +34,19 @@ import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
import { encodeNoffer } from '../noffer.js';
import { maybeBolt11 } from '../ark/lightning.js';
import { withRequestLock } from '../nwc-lock.js';
+import { budgetWindow, budgetRenewsAt, renewalPeriod, spentInWindow, PERIODS } from '../nwc-budget.js';
const REQ_KIND = 23194;
const RES_KIND = 23195;
const INFO_KIND = 13194;
// What we can actually honour. No pay_keysend (Ark can't), no multi_pay.
-const METHODS = ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'];
+const METHODS = ['get_info', 'get_balance', 'get_budget', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'];
+
+// A revoked connection keeps answering — with UNAUTHORIZED — for this long,
+// so the app on the other end can tell "revoked" from "phone is off". After
+// that its key is forgotten everywhere: no subscription, no push, no mirror.
+const REVOKE_GRACE_MS = 30 * 86_400_000;
// Requests older than this are ignored — a relay replaying history must not
// re-trigger payments.
@@ -88,10 +94,22 @@ export function nwcFeature(ctx) {
// ---- persisted connections -------------------------------------------
// { id, name, secret (client sk hex), clientPk, servicePk, serviceSk,
- // maxSat, dailySat, spentToday, spentDate, created, lastUsed, revoked }
+ // maxSat, dailySat (the budget per period — the field predates periods),
+ // period (day|week|month), spentToday (spent this window), spentDate (the
+ // window's key), created, lastUsed, revoked, revokedAt }
const load = () => wallet.loadFeatureState('nwc', { conns: [] });
const save = (st) => wallet.saveFeatureState('nwc', st);
const conns = () => (load().conns || []).filter((c) => !c.revoked);
+ // Revoked lately: still listened for, still woken for, answered with a
+ // refusal. One revoked before revocations were dated gets no grace.
+ const revokedConns = () => (load().conns || [])
+ .filter((c) => c.revoked && c.revokedAt && Date.now() - c.revokedAt < REVOKE_GRACE_MS);
+ // Every service key this device must hear requests for.
+ const watchPks = () => {
+ const pks = [...conns(), ...revokedConns()].map((c) => c.servicePk);
+ if (load().offer) pks.push(load().offer.pk);
+ return pks;
+ };
function updateConn(id, patch) {
const st = load();
@@ -102,11 +120,11 @@ export function nwcFeature(ctx) {
return c;
}
- // Budget accounting, reset on date change.
+ // Budget accounting: the counter resets when the window (a UTC day, week
+ // or month — nwc-budget.js) moves on.
function spendRoom(c) {
- const today = new Date().toISOString().slice(0, 10);
- const spent = c.spentDate === today ? (c.spentToday || 0) : 0;
- return { today, spent, left: Math.max(0, (c.dailySat || 0) - spent) };
+ const { window: today, spent, left } = spentInWindow(c);
+ return { today, spent, left };
}
function recordSpend(c, sat) {
const { today, spent } = spendRoom(c);
@@ -130,7 +148,7 @@ export function nwcFeature(ctx) {
return `nostr+walletconnect://${c.servicePk}?${rs}&secret=${c.secret}`;
}
- function createConn({ name, maxSat, dailySat }) {
+ function createConn({ name, maxSat, dailySat, period }) {
const st = load();
st.conns = st.conns || [];
// A dedicated service key per connection: revoking one can't affect the
@@ -146,6 +164,7 @@ export function nwcFeature(ctx) {
clientPk: getPublicKey(clientSk),
maxSat: maxSat || 10000,
dailySat: dailySat || 50000,
+ period: PERIODS.includes(period) ? period : 'day',
spentToday: 0,
spentDate: null,
created: Date.now(),
@@ -179,6 +198,7 @@ export function nwcFeature(ctx) {
by.set(c.id, {
...prev, ...c,
revoked: !!(prev.revoked || c.revoked),
+ revokedAt: [prev.revokedAt, c.revokedAt].filter(Boolean).sort((x, y) => x - y)[0],
// keep the highest spend seen for the day so a stale device can't
// hand a connection back its budget
spentDate: (c.spentDate === prev.spentDate) ? c.spentDate : (c.spentDate || prev.spentDate),
@@ -311,11 +331,21 @@ export function nwcFeature(ctx) {
// from a stale value on every request (spends never accumulate), and a
// connection revoked on another device would keep being served here.
const cur = (load().conns || []).find((x) => x.id === c.id);
- if (!cur || cur.revoked) return dlog('nwc: guard drop — connection gone or revoked', c.id);
+ if (!cur) return dlog('nwc: guard drop — connection gone', c.id);
c = cur;
const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2'
? 'nip44_v2' : 'nip04';
+ // Revoked: say so. Silence here reads, from the other app's side, exactly
+ // like a phone that is off — the one thing a subscription service cannot
+ // tell apart on its own. The method name is read for the result_type
+ // when the request opens; nothing in it is acted on.
+ if (c.revoked) {
+ let rt = 'error';
+ try { rt = JSON.parse(await decrypt(scheme, hex.decode(c.serviceSk), ev.pubkey, ev.content))?.method || rt; } catch {}
+ lastSeen = { at: Date.now(), method: rt, scheme };
+ return reply(c, ev, scheme, { result_type: rt, ...errRes('UNAUTHORIZED', 'this connection was revoked') });
+ }
let req;
try {
req = JSON.parse(await decrypt(scheme, hex.decode(c.serviceSk), ev.pubkey, ev.content));
@@ -413,6 +443,14 @@ export function nwcFeature(ctx) {
return { result: { balance: (hook('arkSpendableSat') || 0) * 1000 } };
}
+ if (method === 'get_budget') {
+ const { spent } = spendRoom(c);
+ return { result: {
+ used_budget: spent * 1000, total_budget: (c.dailySat || 0) * 1000,
+ renews_at: budgetRenewsAt(c.period || 'day'), renewal_period: renewalPeriod(c.period),
+ } };
+ }
+
if (method === 'pay_invoice') {
const invoice = params.invoice;
if (!invoice) return errRes('OTHER', 'missing invoice');
@@ -434,7 +472,7 @@ export function nwcFeature(ctx) {
return errRes('QUOTA_EXCEEDED', `over the ${c.maxSat} sat per-payment limit`);
}
if (dec.amountSat > left) {
- return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`);
+ return errRes('QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(c.period)} budget (${left} sat)`);
}
let res;
try {
@@ -624,7 +662,7 @@ export function nwcFeature(ctx) {
(ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); },
));
}
- const list = conns();
+ const list = [...conns(), ...revokedConns()];
dlog(`nwc: listen() gen ${gen} — ${list.length} connection(s)`);
for (const c of list) {
dlog(`nwc: gen ${gen} subscribing for conn=${c.id} service=${c.servicePk.slice(0, 8)}`);
@@ -643,7 +681,7 @@ export function nwcFeature(ctx) {
// re-runs on every sync snapshot and wake, and relays rate-limit
// accepted events per IP — repeated republishing eats the budget the
// replies need (and once got this IP temp-banned).
- if (!infoPublished.has(c.id)) {
+ if (!c.revoked && !infoPublished.has(c.id)) {
infoPublished.add(c.id);
publishInfo(c).catch(() => {});
}
@@ -682,8 +720,7 @@ export function nwcFeature(ctx) {
async function enableBackground() {
const up = upEndpoint();
if (up) {
- const pksUp = conns().map((c) => c.servicePk);
- if (load().offer) pksUp.push(load().offer.pk);
+ const pksUp = watchPks();
if (pksUp.length) {
const r = await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
@@ -716,8 +753,7 @@ export function nwcFeature(ctx) {
throw e;
}
{ const st2 = load(); if (st2.pushUnavailable) { delete st2.pushUnavailable; save(st2); } }
- const pks1 = conns().map((c) => c.servicePk);
- if (load().offer) pks1.push(load().offer.pk);
+ const pks1 = watchPks();
// Arming can precede the first connection (creation requires it) — the
// notifier rejects an empty watch list, so defer that POST to the
// refreshRegistration() the creation triggers moments later.
@@ -792,8 +828,7 @@ export function nwcFeature(ctx) {
clearTimeout(regTimer);
regTimer = setTimeout(async () => {
try {
- const pks = conns().map((c) => c.servicePk);
- if (load().offer) pks.push(load().offer.pk);
+ const pks = watchPks();
if (!pks.length) return;
const up2 = upEndpoint();
const sub = up2 ? { endpoint: up2, unifiedpush: true }
@@ -835,7 +870,7 @@ export function nwcFeature(ctx) {
bgTimer = setTimeout(async () => {
try {
if (!hook('arkBgReady')) return;
- await hook('arkBgWrite', conns(), load().offer || null,
+ await hook('arkBgWrite', [...conns(), ...revokedConns()], load().offer || null,
{ paySent: load().paySentNotify !== false });
} catch (e) { console.warn('nwc: could not write background state', e.message); }
}, 1500);
@@ -888,7 +923,7 @@ export function nwcFeature(ctx) {
// No connections means nothing here could have been asked of us: end the
// job now rather than hold a WebView open for half a minute on the
// strength of a payload we can't act on.
- if (!conns().length) { finish(); return; }
+ if (!conns().length && !revokedConns().length) { finish(); return; }
listen(); // the request is on a relay; this is what hears it
reconcileBg(); // and make sure the pouch behind it is current
// an answer published is the signal we were woken for
@@ -921,6 +956,10 @@ export function nwcFeature(ctx) {
// ---- UI ---------------------------------------------------------------
+ const periodKey = (p) => (p === 'month' ? 'nwcMonth' : p === 'week' ? 'nwcWeek' : 'nwcDay');
+ const periodEvery = (p) => (p === 'month' ? 'nwcPerMonth' : p === 'week' ? 'nwcPerWeek' : 'nwcPerDay');
+ const spentKey = (p) => (p === 'month' ? 'nwcSpentMonth' : p === 'week' ? 'nwcSpentWeek' : 'nwcSpentDay');
+
function nwcCard() {
if (!hook('arkReady')) return null;
const list = conns();
@@ -943,14 +982,15 @@ export function nwcFeature(ctx) {
h('div', { class: 'row between' },
h('span', {}, c.name),
h('span', { class: 'linklike small', onClick: () => {
- updateConn(c.id, { revoked: true }); listen();
- refreshRegistration(); // stop waking this device for a dead key
+ updateConn(c.id, { revoked: true, revokedAt: Date.now() }); listen();
+ refreshRegistration(); // keep hearing it for a while, to refuse it
+ writeBg(); // the worker refuses it too, from now
try { wallet.saveCache(); } catch {} // propagate the revocation
render();
toast(t('nwcRevoked', { name: c.name }));
} }, t('nwcRevoke'))),
h('div', { class: 'small faint' },
- t('nwcLimits', { max: fmtAmount(c.maxSat), day: fmtAmount(c.dailySat), spent: fmtAmount(spent) })),
+ t('nwcLimitsPer', { max: fmtAmount(c.maxSat), budget: fmtAmount(c.dailySat), per: t(periodKey(c.period)), spent: fmtAmount(spent), spentWhen: t(spentKey(c.period)) })),
ui.nwcShow === c.id
? h('div', { class: 'col', style: 'gap:6px;align-items:center' },
h('div', { html: qrSvg(uriFor(c)) }),
@@ -992,9 +1032,13 @@ export function nwcFeature(ctx) {
h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcMaxPayment')),
h('input', { type: 'number', min: '1', value: st.maxSat,
onInput: (e) => { st.maxSat = e.target.value; } })),
- h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcDailyBudget')),
+ h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcBudget')),
h('input', { type: 'number', min: '1', value: st.dailySat,
onInput: (e) => { st.dailySat = e.target.value; } })),
+ h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcPeriod')),
+ h('select', { class: 'nwc-period', value: st.period || 'day', onChange: (e) => { st.period = e.target.value; } },
+ ...PERIODS.map((p) => h('option', { value: p, selected: (st.period || 'day') === p ? 'selected' : undefined }, t(periodEvery(p)))))),
+ h('div', { class: 'small faint' }, t('nwcPeriodHint')),
(typeof Notification === 'undefined' || Notification.permission !== 'granted')
? h('div', { class: 'small faint' }, t('nwcNotifHint'))
: null,
@@ -1007,12 +1051,12 @@ export function nwcFeature(ctx) {
catch (e) { toast(e.message); return; }
const c = createConn({
name: st.name, maxSat: parseInt(st.maxSat, 10) || 10000,
- dailySat: parseInt(st.dailySat, 10) || 50000,
+ dailySat: parseInt(st.dailySat, 10) || 50000, period: st.period || 'day',
});
ui.nwcNew = null; ui.nwcShow = c.id; render();
} }, t('nwcCreate'))))
: h('button', { class: 'btn-ghost btn-block', onClick: () => {
- ui.nwcNew = { name: '', maxSat: '10000', dailySat: '50000' }; render();
+ ui.nwcNew = { name: '', maxSat: '10000', dailySat: '50000', period: 'day' }; render();
} }, t('nwcAdd')));
}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d327f9d..d364738 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -13,7 +13,9 @@
import { t } from '../i18n.js';
import { npubOf } from '../nostr.js';
-import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js';
+import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
import { decodeNoffer } from '../noffer.js';
import { requestOfferInvoice } from '../clink.js';
import { decodeOffer } from '../bolt12.js';
@@ -134,7 +136,13 @@ export function zapsFeature(ctx) {
z.address = lnTarget.address || z.address;
}
- const params = await fetchPayParams(target.url);
+ let params;
+ try { params = await fetchPayParams(target.url); }
+ catch (e) {
+ // not a payment after all: the site wants to pay US (LUD-03)
+ if (e && e.withdraw) { if (ui.zap === z) beginWithdraw(e.withdraw, z); return; }
+ throw e;
+ }
if (ui.zap !== z) return; // user navigated away
// Name the payee like a human: LNURL metadata usually carries the
// lightning address (text/identifier) — show that instead of a bech32
@@ -290,6 +298,125 @@ export function zapsFeature(ctx) {
}
}
+ // ---- Lightning login (LNURL-auth) ----
+ // A site's login lnurl pasted (or tapped) into Send: not a payment but a
+ // challenge to sign. Ask first — it is an identity act, naming the site —
+ // then sign with the key this wallet keeps for that site alone (LUD-05)
+ // and tell the site. (Adam, 2026-10-16: a Stacker News login lnurl in Send
+ // got "no sig query variable provided" — it was being paid.)
+ function beginLogin(auth) {
+ ui.zap = null; ui.sendError = '';
+ ui.lnAuth = { auth, status: 'ask' };
+ render();
+ }
+ const masterKey = () => {
+ if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || ''));
+ if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv);
+ return null;
+ };
+ async function doLogin(a) {
+ a.status = 'busy'; render();
+ try {
+ const master = masterKey();
+ if (!master) throw new Error(t('lnAuthNoKey'));
+ await lnurlAuthLogin(a.auth, lnurlAuthKey(master, a.auth.domain));
+ if (ui.lnAuth !== a) return;
+ a.status = 'done';
+ } catch (e) {
+ if (ui.lnAuth !== a) return;
+ a.status = 'error'; a.error = e.message || String(e);
+ }
+ render();
+ }
+ function lnAuthView() {
+ const a = ui.lnAuth;
+ if (!a) return null;
+ const { domain, action } = a.auth;
+ const back = () => { ui.lnAuth = null; ui.sendError = ''; ui.send = blankSend(); render(); };
+ const heading = '🔑 ' + t('lnAuthTitle');
+ if (a.status === 'busy') {
+ return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' },
+ h('span', { class: 'spinner' }),
+ h('p', { class: 'muted', style: 'margin:0' }, t('lnAuthSigning', { domain })));
+ }
+ if (a.status === 'done') {
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' },
+ h('div', { class: 'check-badge' }, '✓'),
+ h('h3', { style: 'margin:0' }, t('lnAuthDoneTitle', { domain })),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthDoneHint')),
+ h('button', { class: 'btn-primary btn-block', onClick: back }, t('done')));
+ }
+ const ask = t(action === 'register' ? 'lnAuthAskRegister' : action === 'link' ? 'lnAuthAskLink' : action === 'auth' ? 'lnAuthAskAuth' : 'lnAuthAskLogin', { domain });
+ return h('div', { class: 'card col', style: 'gap:12px' },
+ h('h3', {}, heading),
+ h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain),
+ h('p', { class: 'small muted', style: 'margin:0' }, ask),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthHow')),
+ a.status === 'error' ? h('div', { class: 'notice err' }, a.error || t('lnAuthFailed')) : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doLogin(a) }, a.status === 'error' ? t('lnAuthRetry') : t(action === 'register' ? 'lnAuthGoRegister' : 'lnAuthGo')),
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
+ }
+ // ---- Lightning withdraw (LNURL-withdraw) ----
+ // A site's withdraw lnurl: they will pay an invoice of ours, within their
+ // range. Ask the amount (their max to start; fixed when min = max), make
+ // the invoice over Ark, hand it over, and watch for the money.
+ function beginWithdraw(w, z) {
+ const maxSat = Math.floor(w.maxMsat / 1000), minSat = Math.ceil(w.minMsat / 1000);
+ ui.zap = null; ui.sendError = '';
+ ui.lnWithdraw = { w, minSat, maxSat, fixed: minSat >= maxSat, amount: ctx.amountField(maxSat), status: 'ask', address: z && z.address };
+ render();
+ }
+ async function doWithdraw(d) {
+ const sats = d.fixed ? d.maxSat : (parseAmount(d.amount, getUnit()) || 0);
+ if (!(sats >= Math.max(1, d.minSat) && sats <= d.maxSat)) { d.error = t('lnWithdrawRange', { min: fmtAmount(d.minSat), max: fmtAmount(d.maxSat), unit: unitLabel() }); render(); return; }
+ if (wallet.watchOnly) { d.error = t('lnWithdrawWatchOnly'); render(); return; }
+ d.sats = sats; d.status = 'busy'; d.error = ''; render();
+ try {
+ const inv = await hook('arkLnInvoice', sats, (d.w.description || t('lnWithdrawMemo', { domain: d.w.domain })).slice(0, 120));
+ if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice'));
+ if (ui.lnWithdraw !== d) { hook('arkLnCancel', inv.id); return; }
+ await submitWithdraw(d.w, inv.invoice);
+ if (ui.lnWithdraw !== d) return;
+ d.status = 'sent'; d.lnId = inv.id;
+ hook('arkLnWatch', inv.id, (a) => { if (ui.lnWithdraw !== d) return; d.status = a && a.step === 'done' ? 'paid' : 'error'; if (d.status === 'error') d.error = t('lnWithdrawNotPaid'); render(); });
+ } catch (e) {
+ if (ui.lnWithdraw !== d) return;
+ d.status = 'error'; d.error = e.message || String(e);
+ }
+ render();
+ }
+ function lnWithdrawView() {
+ const d = ui.lnWithdraw;
+ if (!d) return null;
+ const domain = d.w.domain;
+ const back = () => { if (d.lnId && d.status !== 'paid') hook('arkLnUnwatch', d.lnId); ui.lnWithdraw = null; ui.sendError = ''; ui.send = blankSend(); render(); };
+ const heading = '⚡ ' + t('lnWithdrawTitle');
+ if (d.status === 'busy') {
+ return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' },
+ h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnWithdrawAsking', { domain })));
+ }
+ if (d.status === 'sent' || d.status === 'paid') {
+ const paid = d.status === 'paid';
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' },
+ paid ? h('div', { class: 'check-badge' }, '✓') : h('span', { class: 'spinner' }),
+ h('h3', { style: 'margin:0' }, paid ? t('lnWithdrawPaid', { amount: fmtAmount(d.sats) + ' ' + unitLabel() }) : t('lnWithdrawSent', { amount: fmtAmount(d.sats) + ' ' + unitLabel(), domain })),
+ paid ? null : h('p', { class: 'small muted', style: 'margin:0' }, t('lnWithdrawSentHint')),
+ h('button', { class: paid ? 'btn-primary btn-block' : 'btn-ghost btn-block', onClick: back }, paid ? t('done') : t('back')));
+ }
+ return h('div', { class: 'card col', style: 'gap:12px' },
+ h('h3', {}, heading),
+ h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain),
+ d.w.description ? h('p', { class: 'small muted', style: 'margin:0' }, d.w.description) : null,
+ h('p', { class: 'small muted', style: 'margin:0' }, d.fixed ? t('lnWithdrawFixed', { amount: fmtAmount(d.maxSat) + ' ' + unitLabel(), domain })
+ : t('lnWithdrawOffer', { min: fmtAmount(d.minSat), max: fmtAmount(d.maxSat), unit: unitLabel(), domain })),
+ d.fixed ? null : h('div', { class: 'row gap6', style: 'align-items:center' },
+ h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: d.amount, onInput: (e) => { d.amount = e.target.value; d.error = ''; render(); } }),
+ unitTag(),
+ h('button', { type: 'button', onClick: () => { d.amount = ctx.amountField(d.maxSat); d.error = ''; render(); } }, t('max'))),
+ d.error ? h('div', { class: 'notice err' }, d.error) : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doWithdraw(d) }, d.status === 'error' ? t('lnAuthRetry') : t('lnWithdrawGo')),
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
+ }
function zapView() {
const z = ui.zap;
if (!z) return null;
@@ -391,6 +518,9 @@ export function zapsFeature(ctx) {
id: 'zaps',
// A lightning address / lnurl (always), or an npub that Ark didn't claim.
matchSendText(text, typed) {
+ // a login challenge needs no balance, only the wallet's key
+ const auth = parseLnurlAuth(text);
+ if (auth) { beginLogin(auth); return true; }
if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
const tap = !typed; // scanned/pasted (a tap), not a keystroke
const nof = maybeNoffer(text);
@@ -472,8 +602,8 @@ export function zapsFeature(ctx) {
begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
return true;
},
- resetSend() { ui.zap = null; },
- sendView() { return zapView(); },
+ resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; },
+ sendView() { return lnAuthView() || lnWithdrawView() || zapView(); },
// Settings → Nostr: the one-tap zap amount.
nostrSettingsCards() {
const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
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 fd192a1..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…",
@@ -280,6 +284,17 @@ const STR = {
pagerNextPic: 'Next picture',
videoNoPicture: "This browser can’t show this video’s picture",
videoOpenFile: "Open the file",
+ wnDevicesTitle: 'Group invitations',
+ wnDevicesHelp: 'White Noise and other Marmot apps invite one of your devices, and only that device gets the group. Keep the invitations coming to the device you actually use.',
+ wnDeviceHere: 'This device can be invited.',
+ wnDeviceRetired: 'This device no longer takes invitations; another one of yours does.',
+ wnDeviceNone: 'This device has no invitation key published yet.',
+ wnDevicesOthers: '{n} other invitation key(s) of yours are published:',
+ wnDevicesNoOthers: 'No other device of yours is published.',
+ wnDeviceUnknown: 'another app',
+ wnOnlyHere: 'Invite this device only',
+ wnOnlyHereDone: 'Done. {n} other invitation key(s) retired.',
+ wnOnlyHereFail: 'Couldn’t retire the other keys. Try again.',
videoLargeTitle: "Videos",
videoLargeToggle: "Autoplay large videos",
videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.",
@@ -292,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',
@@ -328,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",
@@ -520,6 +537,7 @@ const STR = {
postBlocked: 'Blocked',
zapSending: 'Sending your zap…',
msgReply: 'Reply',
+ mediaPicGone: 'This picture couldn’t be loaded',
mediaOpenPost: 'Open the post',
profNewPost: 'New post',
profYourProfile: 'Your profile',
@@ -1432,6 +1450,19 @@ const STR = {
nwcRevoke: 'Revoke',
nwcRevoked: 'Revoked {name}',
nwcLimits: 'Max {max}/payment · {day}/day · {spent} spent today',
+ nwcLimitsPer: 'Max {max}/payment · {budget} a {per} · {spent} {spentWhen}',
+ nwcSpentDay: 'spent today',
+ nwcSpentWeek: 'spent this week',
+ nwcSpentMonth: 'spent this month',
+ nwcBudget: 'Budget (sats)',
+ nwcPeriod: 'Budget resets',
+ nwcPerDay: 'Every day',
+ nwcPerWeek: 'Every week',
+ nwcPerMonth: 'Every month',
+ nwcDay: 'day',
+ nwcWeek: 'week',
+ nwcMonth: 'month',
+ nwcPeriodHint: 'Resets at midnight UTC: at the start of the day, the week (Monday) or the month.',
arkLnFundsPending: 'Your money is settling from a previous payment. Retrying…',
lnZapOffer: 'Zap {addr}',
arkLnInvoiceIntro: 'Or take a Lightning payment, straight into Spending.',
@@ -1466,6 +1497,33 @@ const STR = {
destPlaceholder: 'Address or Lightning invoice',
lnQuoting: 'Getting a quote…',
lnPayTitle: 'Pay over Lightning',
+ lnAuthTitle: 'Log in with Lightning',
+ lnAuthAskLogin: '{domain} is asking you to log in with your wallet.',
+ lnAuthAskRegister: '{domain} is asking you to create an account with your wallet.',
+ lnAuthAskLink: '{domain} is asking to link your wallet to your account there.',
+ lnAuthAskAuth: '{domain} is asking you to confirm it’s you, with your wallet.',
+ lnAuthHow: 'Your wallet signs their challenge with a key it keeps for this site alone. No payment is made, and nothing else about you is shared.',
+ lnAuthGo: 'Log in',
+ lnAuthGoRegister: 'Create account',
+ lnAuthRetry: 'Try again',
+ lnAuthSigning: 'Signing in to {domain}…',
+ lnAuthDoneTitle: 'Logged in to {domain}',
+ lnAuthDoneHint: 'Go back to their page — it should have let you in.',
+ lnAuthFailed: 'They didn’t accept the login.',
+ lnAuthNoKey: 'This account has no wallet key to log in with.',
+ lnWithdrawTitle: 'Withdraw over Lightning',
+ lnWithdrawOffer: '{domain} will pay you between {min} and {max} {unit}. How much?',
+ lnWithdrawFixed: '{domain} will pay you {amount}.',
+ lnWithdrawRange: 'Between {min} and {max} {unit}.',
+ lnWithdrawGo: 'Withdraw',
+ lnWithdrawAsking: 'Asking {domain} to pay…',
+ lnWithdrawSent: '{domain} has your invoice for {amount}',
+ lnWithdrawSentHint: 'It should arrive in a moment. You can leave this page; the payment will show in your history.',
+ lnWithdrawPaid: 'Received {amount}',
+ lnWithdrawNotPaid: 'They took the invoice but the payment didn’t arrive. Check with them before trying again.',
+ lnWithdrawNoInvoice: 'Couldn’t make an invoice to be paid to.',
+ lnWithdrawWatchOnly: 'This device can’t receive: the wallet is watch-only here.',
+ lnWithdrawMemo: 'Withdrawal from {domain}',
lnPayAmount: 'Amount',
lnPayBoltzFee: 'Swap fee',
lnPayNetworkFee: 'Network fee',
diff --git a/src/lnurl.js b/src/lnurl.js
index 3f1b39b..bfebf0d 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -11,6 +11,10 @@
// that broadcasts the receipt; we only sign the request.
import { bech32 } from '@scure/base';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { hmac } from '@noble/hashes/hmac';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { parseNostrPubkey } from './nostr.js';
const enc = new TextEncoder();
@@ -43,6 +47,9 @@ export function lnAddressToUrl(addr) {
export function parseZapTarget(input, { pk = null } = {}) {
const s = (input || '').trim().replace(/^lightning:/i, '');
if (!s) return null;
+ // LUD-17: lnurlp:// (pay) and lnurlw:// (withdraw) are the URL itself over https (http on an onion)
+ const scheme = /^(lnurlp|lnurlw):\/\/(.+)$/i.exec(s);
+ if (scheme) { const url = (/^[^/]+\.onion(\/|$)/i.test(scheme[2]) ? 'http://' : 'https://') + scheme[2]; return { kind: 'lnurl', url, lnurlBech32: safeEncode(url), pk }; }
// An npub/hex pubkey — the caller resolves lud16/lud06 from the profile and
// calls fromProfile() below; here we just report the shape.
const npk = parseNostrPubkey(s);
@@ -92,10 +99,14 @@ async function fetchJson(url, ms = 8000) {
}
// GET the LNURL-pay params. Normalizes the bits we use; keeps `callback`.
+// An lnurl that turns out to be a withdraw (LUD-03) is not an error here:
+// the thrown error carries its terms (`withdraw`) for the caller to take up.
export async function fetchPayParams(url) {
const d = await fetchJson(url);
if (!d || d.tag !== 'payRequest' || !d.callback) {
if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error');
+ const w = withdrawParamsOf(d);
+ if (w) { const e = new Error('a Lightning withdraw, not a payment'); e.withdraw = w; throw e; }
throw new Error('not a Lightning pay endpoint');
}
return {
@@ -134,3 +145,71 @@ export async function requestInvoice(params, { amountMsat, zapRequest = null, ln
if (!d.pr || typeof d.pr !== 'string') throw new Error('no invoice returned');
return d.pr;
}
+
+// ---- LNURL-auth (LUD-04): a login challenge, not a payment ----
+// An lnurl whose URL carries tag=login and a 32-byte k1. Pasted into Send
+// and taken for a payment, their server answered "no sig query variable
+// provided" (Stacker News, 2026-10-16). action: login | register | link | auth.
+export function parseLnurlAuth(input) {
+ const s = (input || '').trim().replace(/^lightning:/i, '');
+ if (!/^lnurl1[ac-hj-np-z02-9]+$/i.test(s)) return null;
+ let url;
+ try { url = new URL(decodeLnurl(s)); } catch { return null; }
+ if (url.searchParams.get('tag') !== 'login') return null;
+ const k1 = (url.searchParams.get('k1') || '').toLowerCase();
+ if (!/^[0-9a-f]{64}$/.test(k1)) return null;
+ const action = /^(register|login|link|auth)$/.test(url.searchParams.get('action') || '') ? url.searchParams.get('action') : 'login';
+ return { url: url.href, domain: url.hostname, k1, action };
+}
+// The linking key for a domain (LUD-05): from the wallet's master key,
+// m/138'/0 is the hashing key; its HMAC-SHA256 of the domain, read as four
+// 32-bit numbers, names the path m/138'/<n1>/<n2>/<n3>/<n4>. The same
+// wallet is the same user on a site, every time, and a different one on
+// every other site.
+export function lnurlAuthKey(master, domain) {
+ const hashing = master.derive("m/138'/0").privateKey;
+ const mac = hmac(sha256, hashing, new TextEncoder().encode(domain));
+ const dv = new DataView(mac.buffer, mac.byteOffset, 16);
+ let key = master.derive("m/138'");
+ for (const o of [0, 4, 8, 12]) key = key.deriveChild(dv.getUint32(o));
+ return key;
+}
+// Sign the challenge (ECDSA over k1, DER-encoded) and call the URL back with
+// sig and key. Resolves on the server's OK; throws its reason otherwise.
+export async function lnurlAuthLogin(auth, key) {
+ const sig = secp256k1.sign(hexToBytes(auth.k1), key.privateKey);
+ const url = new URL(auth.url);
+ url.searchParams.set('sig', sig.toDERHex());
+ url.searchParams.set('key', bytesToHex(key.publicKey));
+ const d = await fetchJson(url.href);
+ if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'login refused');
+ return { key: bytesToHex(key.publicKey) };
+}
+
+// ---- LNURL-withdraw (LUD-03): the other side asks to PAY us ----
+// Their terms: an amount range (msat), a callback and k1. We make an invoice
+// for the amount chosen and hand it to the callback; they pay it.
+export function withdrawParamsOf(d) {
+ if (!d || d.tag !== 'withdrawRequest' || !d.callback || !d.k1) return null;
+ const min = Math.max(0, Number(d.minWithdrawable) || 0), max = Number(d.maxWithdrawable) || 0;
+ if (!(max > 0)) return null;
+ let domain = '';
+ try { domain = new URL(d.callback).hostname; } catch {}
+ return { callback: d.callback, k1: String(d.k1), minMsat: min, maxMsat: Math.max(min, max), description: String(d.defaultDescription || '').slice(0, 200), domain };
+}
+export async function fetchWithdrawParams(url) {
+ const d = await fetchJson(url);
+ const w = withdrawParamsOf(d);
+ if (!w) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); throw new Error('not a Lightning withdraw'); }
+ return w;
+}
+// Hand them the invoice to pay. Their OK means they have it, not that it is paid.
+export async function submitWithdraw(params, invoice) {
+ const url = new URL(params.callback);
+ url.searchParams.set('k1', params.k1);
+ url.searchParams.set('pr', invoice);
+ const d = await fetchJson(url.href);
+ if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'withdraw refused');
+ return true;
+}
+
diff --git a/src/marmot-client.js b/src/marmot-client.js
index 4a79b21..b514216 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -26,6 +26,7 @@ const DIRECTORY_RELAYS = ['wss://relay.eu.whitenoise.chat', 'wss://relay.us.whit
const DAY = 86400;
const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime
+const RETIRE_CHECK = 6 * 3600; // how often a device asks whether another one took the invitations
const REACH_TTL = 10 * 60_000;
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -98,6 +99,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
async function ensure(opts) {
await load();
+ if (await checkRetired()) return false; // another device took the invitations: ours is not put back
const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true;
const s = await signer(opts);
@@ -150,6 +152,76 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
return true;
}
+ // ---- which device gets the invitations ----
+ // Every device publishes its own KeyPackage, and a Marmot inviter (White
+ // Noise) picks ONE of them: the invitation lands on whichever device it
+ // chose, and the others never see the group (Adam, 2026-10-14: the
+ // phone's). "Only here" retires the account's other packages — a kind 5
+ // naming each, NIP-09 `a` for the addressable 30443s and `e` for an old
+ // 443 — and publishes a fresh one of ours; a device whose package was
+ // retired sees the deletion (newer than its package) and stops putting
+ // one back, until "only here" is pressed on it in turn.
+ const tagOf = (e, k) => (e.tags.find((t) => t[0] === k) || [])[1] || '';
+ async function devices() {
+ await load();
+ await checkRetired();
+ const own = await ownWriteRelays().catch(() => []);
+ const where = [...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])];
+ const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE, 443], authors: [pubkey] }, 3000).catch(() => []);
+ const bySlot = new Map();
+ for (const e of evs) {
+ if (e.pubkey !== pubkey) continue;
+ if (e.kind === 443) { bySlot.set('e:' + e.id, { id: e.id, slot: null, created_at: e.created_at, client: tagOf(e, 'client'), legacy: true }); continue; }
+ const d = tagOf(e, 'd');
+ if (!d) continue;
+ const cur = bySlot.get(d);
+ if (!cur || e.created_at > cur.created_at) bySlot.set(d, { id: e.id, slot: d, created_at: e.created_at, client: tagOf(e, 'client') });
+ }
+ const mine = dev ? dev.slot : null;
+ const list = [...bySlot.values()];
+ const ours = (x) => !!mine && x.slot === mine; // (an old 443 has no slot: never ours)
+ return {
+ slot: mine, retired: !!(dev && dev.retired), published: list.some(ours),
+ others: list.filter((x) => !ours(x)).sort((a, b) => b.created_at - a.created_at),
+ };
+ }
+ async function onlyHere(opts = {}) {
+ await load();
+ const s = await signer(opts);
+ if (!s) throw new Error('no signer');
+ if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); }
+ const d = await devices();
+ if (d.others.length) {
+ const tags = [['k', String(M.KIND.KEY_PACKAGE)], ['k', '443']];
+ for (const o of d.others) {
+ tags.push(['e', o.id]);
+ if (o.slot) tags.push(['a', M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + o.slot]);
+ }
+ const ev = await s.sign({ kind: M.KIND.DELETE, created_at: now(), tags, content: 'group invitations go to one device' });
+ const own = await ownWriteRelays().catch(() => []);
+ if (!await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])], ev)) throw new Error('not published');
+ }
+ // ours, fresh: newer than any deletion that ever named this slot
+ if (dev.retired) { dev.retired = false; await saveDev(); }
+ dev.retireCheckedAt = now();
+ if (!await publishKeyPackage(s)) throw new Error('not published');
+ return d.others.length;
+ }
+ // once in a while: was our package retired on the relays (onlyHere on another device)?
+ async function checkRetired() {
+ const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
+ if (!dev || dev.retired || !cur || now() - (dev.retireCheckedAt || 0) <= RETIRE_CHECK) return !!(dev && dev.retired);
+ dev.retireCheckedAt = now(); await saveDev();
+ if (await retiredOnRelays(cur)) { dev.retired = true; await saveDev(); }
+ return dev.retired === true;
+ }
+ async function retiredOnRelays(cur) {
+ const own = await ownWriteRelays().catch(() => []);
+ const evs = await queryOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])],
+ { kinds: [M.KIND.DELETE], authors: [pubkey], '#a': [M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + dev.slot] }, 2500).catch(() => []);
+ return evs.some((e) => e.pubkey === pubkey && e.created_at > (cur.at || 0));
+ }
+
// Somebody's KeyPackages: one per device (a slot is one installation), the
// best first, at most ten. An invitation adds every one of them, so a person
// with a phone and a laptop gets the group on both — the rule Marmot's
@@ -440,7 +512,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
return {
- pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept,
+ pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, devices, onlyHere,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags),
// attachments: seal for the epoch a message is about to go out in, and
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/nwc-bg.js b/src/nwc-bg.js
index 92786c0..3bdef08 100644
--- a/src/nwc-bg.js
+++ b/src/nwc-bg.js
@@ -88,6 +88,11 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra
const keys = {};
const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex));
for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i);
+ // A standing renewal order (and a renewal in flight) burned its output key
+ // when it was registered, below the counter by now: the worker needs that
+ // key to claim the renewed coin while the app is closed.
+ for (const e of mgrState.scheduled || []) if (e.outKeyIndex != null) idx.add(e.outKeyIndex);
+ for (const a of mgrState.actions || []) if (a.type === 'refresh' && a.step !== 'done' && a.outKeyIndex != null) idx.add(a.outKeyIndex);
idx.add(0); // the receive-address key: mailbox/refund defaults land on it
for (const i of idx) keys[String(i)] = keyFor(3, i);
const keys5 = {};
@@ -107,9 +112,14 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra
// { on, threshold, keep, target: { kind, address, url?, spkHex? } } — the
// destination is resolved by the app, so the worker never needs a relay
autowithdraw: autowithdraw || null,
+ // The budget's spent counter rides along (stamped with its window) so the
+ // worker counts what the app spent; a revoked one rides along flagged, to
+ // be refused rather than ignored.
connections: (connections || []).map((c) => ({
id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
- maxSat: c.maxSat, dailySat: c.dailySat,
+ maxSat: c.maxSat, dailySat: c.dailySat, period: c.period || 'day',
+ spentDate: c.spentDate || null, spentToday: c.spentToday || 0,
+ revoked: !!c.revoked,
})),
};
}
diff --git a/src/nwc-budget.js b/src/nwc-budget.js
new file mode 100644
index 0000000..a16e068
--- /dev/null
+++ b/src/nwc-budget.js
@@ -0,0 +1,54 @@
+// An NWC connection's spending budget resets on a calendar boundary — a day,
+// a week or a month — in UTC, so every device of a wallet (and the service
+// worker answering while the app is closed) agrees on which window a spend
+// fell in without a timezone in the picture. The window key is what the
+// connection's spent counter is stamped with: a counter stamped with another
+// key is from a window that has passed.
+
+export const PERIODS = ['day', 'week', 'month'];
+
+const pad = (n) => String(n).padStart(2, '0');
+
+// The week runs Monday to Sunday (ISO), keyed by its Monday.
+function mondayOf(d) {
+ const m = new Date(d);
+ const dow = (m.getUTCDay() + 6) % 7; // Monday = 0
+ m.setUTCDate(m.getUTCDate() - dow);
+ m.setUTCHours(0, 0, 0, 0);
+ return m;
+}
+
+export function budgetWindow(period, ts = Date.now()) {
+ const d = new Date(ts);
+ if (period === 'month') return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`;
+ if (period === 'week') { const m = mondayOf(d); return `W${m.getUTCFullYear()}-${pad(m.getUTCMonth() + 1)}-${pad(m.getUTCDate())}`; }
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
+}
+
+// When the current window ends, in unix seconds (NIP-47's renews_at).
+export function budgetRenewsAt(period, ts = Date.now()) {
+ const d = new Date(ts);
+ let end;
+ if (period === 'month') end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1);
+ else if (period === 'week') { const m = mondayOf(d); end = m.getTime() + 7 * 86_400_000; }
+ else end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
+ return Math.floor(end / 1000);
+}
+
+// NIP-47 names the period as an adverb.
+export const renewalPeriod = (period) => (period === 'month' ? 'monthly' : period === 'week' ? 'weekly' : 'daily');
+
+// What a connection has spent in the window that is on now: the counter
+// carried on the connection (stamped with its window) plus, for the worker,
+// its own spends the app has not yet folded into that counter.
+export function spentInWindow(conn, extraSpends = [], ts = Date.now()) {
+ const period = conn.period || 'day';
+ const win = budgetWindow(period, ts);
+ let spent = conn.spentDate === win ? (conn.spentToday || 0) : 0;
+ for (const s of extraSpends) {
+ if (s.absorbed || s.connId !== conn.id) continue;
+ if (budgetWindow(period, s.ts) !== win) continue;
+ spent += (s.amountSat || 0) + (s.feeSat || 0);
+ }
+ return { window: win, period, spent, left: Math.max(0, (conn.dailySat || 0) - spent) };
+}
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 27699e7..ec19923 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -20,6 +20,8 @@ import { finalizeEvent } from 'nostr-tools/pure';
import { ArkManager } from './ark/manager.js';
import { maybeBolt11 } from './ark/lightning.js';
import { allBgs, saveBg } from './nwc-bg.js';
+import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js';
+import { backstopMargin } from './ark/manager.js';
const MAX_AGE_SEC = 120;
const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin
@@ -160,11 +162,6 @@ export async function respondFromBg(data, {
if (ev.pubkey !== conn.clientPk) return false;
const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04';
- let req;
- try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
- const method = req?.method;
- const params = req?.params || {};
-
const publish = async (payload) => {
const evt = await buildReply(conn, ev, scheme, payload);
const r = await fetchFn(`${notifier}/publish`, {
@@ -174,6 +171,17 @@ export async function respondFromBg(data, {
if (!r.ok) throw new Error(`publish refused: ${r.status}`);
return true;
};
+ let req;
+ try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
+ const method = req?.method;
+ const params = req?.params || {};
+ // Revoked, lately: refuse out loud — a silent worker looks like a phone
+ // that is off, which is the one thing the other app cannot tell apart.
+ if (conn.revoked) {
+ await publish(errRes(method || 'error', 'UNAUTHORIZED', 'this connection was revoked'));
+ log('refused a request on a revoked connection');
+ return true;
+ }
// Another device (an open tab elsewhere) may have answered already — a
// paid zap must never be shouted over with a late error, and paying twice
// wastes funds-in-flight. The notifier tracks recent reply e-tags.
@@ -204,7 +212,7 @@ export async function respondFromBg(data, {
alias: 'Coinos', color: '#15171a',
network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network,
block_height: 0, block_hash: '',
- methods: ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'],
+ methods: ['get_info', 'get_balance', 'get_budget', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'],
},
});
return true;
@@ -265,6 +273,14 @@ export async function respondFromBg(data, {
await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } });
return true;
}
+ if (method === 'get_budget') {
+ const { spent } = spentInWindow(conn, rec.spends || []);
+ await publish({ result_type: 'get_budget', result: {
+ used_budget: spent * 1000, total_budget: (conn.dailySat || 0) * 1000,
+ renews_at: budgetRenewsAt(conn.period || 'day'), renewal_period: renewalPeriod(conn.period),
+ } });
+ return true;
+ }
if (method === 'list_transactions') {
// the mirrored wallet's Lightning history, both ways, as the open app reports it
const txs = ((rec.mgr && rec.mgr.movements) || [])
@@ -309,12 +325,11 @@ export async function respondFromBg(data, {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`));
return true;
}
- const today = new Date().toISOString().slice(0, 10);
- const spentToday = (rec.spends || [])
- .filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today)
- .reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0);
- if (spentToday + dec.amountSat > conn.dailySat) {
- await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`));
+ // What the app spent this window rides on the mirrored connection; the
+ // worker's own spends the app has not yet folded in are counted from its log.
+ const { left } = spentInWindow(conn, rec.spends || []);
+ if (dec.amountSat > left) {
+ await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`));
return true;
}
if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user
@@ -411,3 +426,49 @@ export async function respondFromBg(data, {
},
};
}
+
+// Renewals while the app is closed. The app registers a standing order with
+// the ASP for every coin (a round it runs on its own, margin blocks before
+// expiry) — but a coin spent by this worker voids its order, and the change
+// left behind has none until the app next opens. A wallet only ever woken by
+// NWC requests would lose cover one spend after its last open. So each wake
+// tends the mirror's coins the way the app does: claim any order whose round
+// has run (the output waits 'unclaimed' until someone forfeits the inputs),
+// and register an order for every coin without one. Both need only what the
+// mirror carries — the coins' keys and a window of upcoming indices.
+// Bounded: the push handler has a budget, and anything left over waits for
+// the next wake or the app.
+export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000, margin = null } = {}) {
+ let n = 0;
+ for (const { walletKey, rec } of await recordsFn()) {
+ if (!rec || rec.v < 3 || !rec.mgr || !rec.ark || !rec.key4) continue;
+ if (Date.now() > deadline - 1500) break;
+ const timeLeft = () => Math.max(0, deadline - Date.now());
+ const bounded = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), timeLeft()))]);
+ try {
+ const mgr = await bounded(bgManager(rec, walletKey, saveFn));
+ await bounded(mgr._tipMemo());
+ // orders whose round has run: claim them (the output becomes spendable
+ // here, and merges into the app's state on its next open). A claim is
+ // several round trips — the funding tx must confirm, then leaf cosign,
+ // forfeit, adopt — so one that ran out of time (or confirmations) last
+ // wake sits as a refresh action in flight: drive it on, every wake.
+ await bounded(mgr._driveScheduled()).catch((e) => log('renewal claim: ' + e.message));
+ for (const a of mgr.state.actions || []) {
+ if (a.type !== 'refresh' || ['done', 'failed'].includes(a.step)) continue;
+ await bounded(mgr._driveRefresh(a)).catch((e) => log('renewal claim: ' + e.message));
+ if (a.step === 'done') { n++; log('claimed a renewal while closed'); }
+ }
+ // coins without an order: register one, as long as the output key the
+ // order burns is inside the mirrored window (the worker can't derive)
+ let room = 0;
+ while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++;
+ if (room) {
+ const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? backstopMargin(rec.ark.network), room)).catch((e) => { log('renewal order: ' + e.message); return 0; });
+ if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); }
+ }
+ await saveFn(walletKey, rec);
+ } catch (e) { log('renewals: ' + e.message); }
+ }
+ return n;
+}
diff --git a/src/style.css b/src/style.css
index 2608d67..af12d49 100644
--- a/src/style.css
+++ b/src/style.css
@@ -92,6 +92,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 {
@@ -285,6 +291,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(--accent); color: var(--accent-fg); }
/* ---- address / qr ---- */
.qr { width: 220px; height: 220px; display: block; border-radius: 12px; border: 1px solid var(--line); }
@@ -1455,7 +1467,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; }
@@ -1505,8 +1523,14 @@ body::after {
.mp-dots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(255, 255, 255, .38); box-shadow: 0 0 3px rgba(0, 0, 0, .5); transition: background .15s, transform .15s; }
.mp-dots i.on { background: #fff; transform: scale(1.15); }
.mp-media { max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain; display: block; user-select: none; -webkit-user-select: none; }
-/* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe */
-.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; will-change: transform; }
+/* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe.
+ Only the slide at rest gets its own layer for that: one on every picture
+ kept a full-size texture per slide, and a flight past them stuttered. */
+.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; }
+.mp-slide.mp-cur img.mp-media { will-change: transform; }
+.mp-slide.mp-gone img.mp-media { display: none; }
+.mp-missing { display: flex; flex-direction: column; align-items: center; gap: 6px; color: rgba(255, 255, 255, .55); font-size: 15px; text-align: center; padding: 20px; }
+.mp-missing-ico { font-size: 44px; opacity: .5; }
/* nothing but the picture until a tap */
.mp-meta, .mp-top { opacity: 0; pointer-events: none; transition: opacity .18s; }
.media-pager.show .mp-meta, .media-pager.show .mp-top { opacity: 1; pointer-events: auto; }
@@ -1527,6 +1551,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;
@@ -1535,6 +1560,9 @@ body::after {
.mp-counts .i { font-size: 24px; line-height: 1; display: flex; }
.mp-counts .i svg { width: 28px; height: 28px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
.mp-like.on { color: #ff4d6d; }
+.mp-like .i .cemoji { width: 28px; height: 28px; }
+/* the emoji sheet over the media pager (z-index 200) */
+.confirm-pop-backdrop.over-pager { z-index: 300; }
.mp-zap.on .i { color: #f7b500; }
.mp-top {
position: absolute; top: max(10px, env(safe-area-inset-top)); left: 10px; right: 10px;
@@ -1546,6 +1574,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. */
@@ -1633,9 +1672,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 {
@@ -1788,6 +1830,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; }
@@ -1807,3 +1854,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/sw-nwc.js b/src/sw-nwc.js
index ddcdbb5..3dac246 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -4,7 +4,7 @@
// nwc-respond.js), and only falls back to the wake-the-user notification
// when it can't.
-import { respondFromBg } from './nwc-respond.js';
+import { respondFromBg, tendRenewals } from './nwc-respond.js';
import { withRequestLock } from './nwc-lock.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js';
@@ -70,6 +70,7 @@ self.addEventListener('push', (e) => {
return;
}
if (data.type !== 'nwc') return;
+ const startedAt = Date.now();
e.waitUntil((async () => {
// An open window handles requests itself with full wallet state — nudge
// it and stay out of the way.
@@ -78,6 +79,11 @@ self.addEventListener('push', (e) => {
for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
return;
}
+ // A wake is the only moment a closed wallet gets to look after its coins:
+ // claim renewals whose round has run, cover coins without one. After the
+ // answer, inside what is left of the push budget.
+ const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) })
+ .catch((err) => console.warn('[sw-renew] failed:', err && err.message));
// an NWC wake-up may also have moved money in; check the rule after
setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0);
let handled = false;
@@ -97,9 +103,10 @@ self.addEventListener('push', (e) => {
tag: handled.notify.tag || 'nwc-incoming', renotify: true,
data: { url: './' },
});
+ await tend();
return;
}
- if (handled) return;
+ if (handled) { await tend(); return; }
// A kind-21001 offer request is money coming IN (someone asking us to
// mint an invoice) — "an app wants to pay" would read as the opposite.
const incoming = data.event && data.event.kind === 21001;
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/tools/ark/bg-renewal-test.js b/tools/ark/bg-renewal-test.js
new file mode 100644
index 0000000..0a85816
--- /dev/null
+++ b/tools/ark/bg-renewal-test.js
@@ -0,0 +1,135 @@
+// The service worker tends a closed wallet's renewals, on regtest, end to end:
+//
+// the app receives a coin and writes the background mirror -> "closes" ->
+// the worker (tendRenewals over the mirror, keys from the mirror alone)
+// registers a standing order for the coin -> the worker SPENDS from the
+// mirror (an NWC pay) which voids that order; the next wake covers the
+// change coin -> the scheduled height passes, the server runs the round
+// with nobody online -> the next wake claims the renewed coin into the
+// mirror -> the app returns and merges the worker's state: fresh coin,
+// old one spent, the order gone.
+//
+// Usage: bun tools/ark/bg-renewal-test.js
+// Needs the ark-regtest stack (bc, ark-electrs, captaind on :3535).
+
+import { execSync } from 'node:child_process';
+import { hex } from '@scure/base';
+import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { HDKey } from '@scure/bip32';
+
+import { ArkManager } from '../../src/ark/manager.js';
+import { buildBg } from '../../src/nwc-bg.js';
+import { tendRenewals, bgManager } from '../../src/nwc-respond.js';
+import { mergeArkStates } from '../../src/features/ark.js';
+import { ALICE, aliceReady } from './alice.js';
+
+const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535';
+const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002';
+const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`);
+const tipHeight = () => Number(sh(`${BCLI} getblockcount`));
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// --- the app's wallet ---
+const store = { raw: null };
+const storage = { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } };
+const mnemonic = generateMnemonic(wordlist);
+const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'");
+const app = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init();
+await aliceReady();
+
+const receive = async (sat) => {
+ const before = app.balance().spendableSat;
+ sh(`${ALICE} -q send ${app.address()} "${sat} sat"`);
+ for (let i = 0; i < 15 && app.balance().spendableSat < before + sat; i++) { await app.sync(); await sleep(1000); }
+ return app.balance().spendableSat - before;
+};
+
+// --- the mirror the worker answers from (what writeBgMirror writes) ---
+let rec = null;
+const writeMirror = () => {
+ rec = buildBg({
+ ark: { arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest', serverPubkey: app.info.serverPubkey },
+ mgrState: JSON.parse(JSON.stringify(app.state)),
+ keyFor: (chain, i) => hex.encode(account.deriveChild(chain).deriveChild(i).privateKey),
+ connections: [],
+ });
+};
+const recs = async () => [{ walletKey: 'w1', rec }];
+const saveFn = async (k, r) => { rec = r; };
+const log = (m) => console.log(' [sw-renew] ' + m);
+const tend = (margin) => tendRenewals({ recordsFn: recs, saveFn, log, deadline: Date.now() + 60_000, margin });
+const mirrorCoins = () => (rec.mgr.vtxos || []).filter((v) => v.state === 'spendable');
+
+console.log('\n[1] the app receives a coin, mirrors it, and closes');
+check('received 20000 sat', (await receive(20000)) === 20000, JSON.stringify(app.balance()));
+writeMirror();
+const coin = mirrorCoins()[0];
+check('the mirror holds the coin and its key', !!coin && !!rec.keys[String(coin.keyIndex)] && !!rec.key4);
+check('no standing order yet, on either side', !(app.state.scheduled || []).length && !(rec.mgr.scheduled || []).length);
+
+console.log('\n[2] a wake: the worker registers a standing order from the mirror alone');
+// aim the order a few blocks out so the test needn't mine a month
+const n1 = await tend(coin.expiryHeight - (tipHeight() + 3));
+const order1 = (rec.mgr.scheduled || [])[0];
+check('one order registered', n1 === 1 && (rec.mgr.scheduled || []).length === 1);
+check('with the server\'s unlock hash', !!order1?.unlockHash, order1?.unlockHash?.slice(0, 16));
+check('the coin stays spendable in the mirror', mirrorCoins().some((v) => v.id === coin.id));
+check('the output key it burned is in the mirror', !!rec.keys[String(order1?.outKeyIndex)]);
+check('the app knows nothing of it yet', !(app.state.scheduled || []).length);
+check('a second wake registers nothing more', (await tend(coin.expiryHeight - (tipHeight() + 3))) === 0 && rec.mgr.scheduled.length === 1);
+
+console.log('\n[3] the worker spends from the mirror (an NWC pay): the order dies, the change gets its own');
+const worker = await bgManager(rec, 'w1', saveFn);
+await worker.send(sh(`${ALICE} -q address`), 5000);
+await worker.sync();
+const change = mirrorCoins().find((v) => v.id !== coin.id);
+check('change coin in the mirror', !!change, change && `${change.amountSat} sat`);
+check('the spent coin\'s order is gone', !(rec.mgr.scheduled || []).some((s) => s.inputIds.includes(coin.id)));
+const n2 = await tend(change.expiryHeight - (tipHeight() + 3));
+const order2 = (rec.mgr.scheduled || []).find((s) => s.inputIds.includes(change.id));
+check('the next wake covers the change coin', n2 === 1 && !!order2, JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds)));
+check('scheduled a few blocks out', order2?.scheduledHeight === tipHeight() + 3, `${order2?.scheduledHeight} vs ${tipHeight() + 3}`);
+
+console.log('\n[4] the round runs with nobody online; the next wake claims it into the mirror');
+mine(3 + 2);
+await sleep(15_000);
+const claimed = () => (rec.mgr.actions || []).some((a) => a.type === 'refresh' && a.step === 'done');
+for (let i = 0; i < 25 && !claimed(); i++) { await tend(change.expiryHeight - (tipHeight() + 3)); mine(1); await sleep(1500); }
+if (!claimed()) console.log(' actions:', JSON.stringify((rec.mgr.actions || []).map((a) => [a.type, a.step, a.lastError])));
+const renewed = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id);
+const renewedId = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id)?.id;
+check('the consumed order is replaced by one for the fresh coin, in the same wake', (rec.mgr.scheduled || []).length === 1 && !!renewedId && rec.mgr.scheduled[0].inputIds.includes(renewedId), JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds)));
+check('the change coin is spent', rec.mgr.vtxos.find((v) => v.id === change.id)?.state === 'spent');
+check('a fresh coin sits in the mirror', !!renewed, renewed && `${renewed.amountSat} sat`);
+check('worth the change minus the scheduled fee', !!renewed && renewed.amountSat === change.amountSat - order2.feeSat, `${renewed?.amountSat} vs ${change?.amountSat} - ${order2?.feeSat}`);
+check('with its expiry clock restarted', !!renewed && renewed.expiryHeight > change.expiryHeight, renewed && `${change.expiryHeight} -> ${renewed.expiryHeight}`);
+check('the renewal is in the mirror\'s history', (rec.mgr.movements || []).some((m) => m.type === 'refresh' && m.status === 'complete'));
+
+console.log('\n[5] the app returns and merges the worker\'s state');
+// what mergeBgWorkerState does on connect: additive merge, the worker's spent flips win, reconcile
+const merged = mergeArkStates(app.state, rec.mgr);
+const workerState = new Map(rec.mgr.vtxos.map((v) => [v.id, v.state]));
+for (const v of merged.vtxos) if (workerState.get(v.id) === 'spent') v.state = 'spent';
+merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1);
+app.state = merged; app._save();
+await app.reconcile().catch((e) => console.log(' reconcile:', e.message));
+await app.sync();
+const appCoins = app.vtxos().filter((v) => v.state === 'spendable');
+check('the app holds the fresh coin and nothing stale', appCoins.length === 1 && appCoins[0].id === renewed?.id, JSON.stringify(appCoins.map((v) => [v.id.slice(0, 8), v.amountSat, v.state])));
+check('balance is the change minus the renewal fee', app.balance().spendableSat === change.amountSat - order2.feeSat, JSON.stringify(app.balance()));
+check('the app adopts the worker\'s order for the fresh coin', (app.state.scheduled || []).length === 1 && app.state.scheduled[0].inputIds.includes(renewed?.id) && app.state.scheduled[0].unlockHash === rec.mgr.scheduled[0].unlockHash, JSON.stringify((app.state.scheduled || []).map((s) => s.inputIds)));
+check('the app is not busy', app.pendingActions().length === 0, JSON.stringify(app.pendingActions().map((a) => [a.type, a.step])));
+check('the renewal shows in the app\'s history once', app.movements().filter((m) => m.type === 'refresh' && m.status === 'complete').length === 1);
+
+console.log(ok ? '\nALL GREEN\n' : '\nFAILURES\n');
+process.exit(ok ? 0 : 1);
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..fe62413 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -126,8 +126,13 @@ 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 mode('images');
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js
index 32625c9..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);
@@ -82,12 +83,14 @@ try {
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000));
- console.log('\n[a tap on the picture that opened it closes it]');
+ console.log('\n[a tap on the picture that opened it shows its details; the next closes it]');
const y0 = await openPic('two pictures');
check('the picture opens in the pager, on itself', await resting('a1.png'), await curPic());
check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show')));
+ await tapPager(); await sleep(400);
+ check('one tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
await tapPager(); await sleep(600);
- check('one tap closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
check('the feed is where it was', Math.abs((await page.evaluate(() => window.scrollY)) - y0) <= 2);
console.log('\n[left and right: along a post\'s pictures; up and down: post to post]');
diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js
index 8a00b11..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,37 +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 hunt: a quarter of a notch's travel, not a flight the other way.
- const back = (before - f.y) * dir;
- assert(back > 65 && back < 90, 'a reverse notch against a coast is a short step: ' + back);
- // Each notch on grows the step back towards a full one; the sixth is a plain notch again.
- const steps = [];
- for (let i = 0; i < 5; i++) { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); steps.push((y - f.y) * dir); }
- assert(steps.every((s, i) => i === 0 || s > steps[i - 1] * 1.08), 'hunting steps grow notch by notch: ' + steps.map(Math.round));
- assert(steps[0] > 95 && steps[0] < 125 && steps[3] > 240 && steps[3] < 285 && steps[4] > 280 && steps[4] < 330, 'from about a third of a notch to a full one: ' + steps.map(Math.round));
- { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a few flicks on, the wheel flies again: ' + s); }
- // A turn back while hunting starts the small steps over.
- f.wheel(-dir); f.advance(17); f.frame(); f.advance(17); f.frame();
- { const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 65 && s < 90, 'a reversal while hunting is a short step again: ' + s); }
- { f.advance(300); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 95 && s < 120, 'and the next grows from there: ' + s); }
- // A pause ends the hunt: the next notch is the plain push from rest.
- { f.advance(2200); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'after a pause a notch is a full push: ' + s); }
- // A turn back against a coast that only just died hunts too.
- { f.wheel(dir); settle(f); f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 65 && s < 90, 'reversing right after the coast dies is a short step: ' + s); }
- // ...but a notch from rest the other way, well after, is a full one.
+ 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 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, precision hunt after a reversal (short steps that grow, restart on another turn back, end on a pause)');
+{ 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/lnurl-auth-test.js b/tools/lnurl-auth-test.js
new file mode 100644
index 0000000..767e85c
--- /dev/null
+++ b/tools/lnurl-auth-test.js
@@ -0,0 +1,102 @@
+// A site's login lnurl (LNURL-auth) pasted into Send is a login, not a
+// payment: the wallet asks first, naming the site, then signs the challenge
+// and tells the site; the site's refusal is shown as its own words. Against
+// a stand-in login server that checks the signature the way a real one does.
+// Run: bun tools/lnurl-auth-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { hexToBytes } from '@noble/hashes/utils';
+import { encodeLnurl } from '../src/lnurl.js';
+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 html = await buildHtml({ minify: true, pwa: false });
+const logins = []; // what the stand-in site was told
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url), p = u.pathname;
+ if (p === '/lnauth') {
+ const sig = u.searchParams.get('sig'), key = u.searchParams.get('key'), k1 = u.searchParams.get('k1');
+ if (!sig) return Response.json({ status: 'ERROR', reason: 'no sig query variable provided' });
+ let good = false;
+ try { good = secp256k1.verify(secp256k1.Signature.fromDER(sig), hexToBytes(k1), hexToBytes(key)); } catch {}
+ logins.push({ k1, key, good });
+ return Response.json(good ? { status: 'OK' } : { status: 'ERROR', reason: 'bad signature' });
+ }
+ if (p === '/lnauth-closed') return Response.json({ status: 'ERROR', reason: 'registrations are closed' });
+ if (p.startsWith('/punks') || p === '/verify-worker.js' || p.startsWith('/locales') || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const k1 = 'c0ffee'.repeat(10) + 'abcd';
+const loginLnurl = encodeLnurl(server.url.origin + '/lnauth?tag=login&k1=' + k1 + '&action=login');
+const closedLnurl = encodeLnurl(server.url.origin + '/lnauth-closed?tag=login&k1=' + k1 + '&action=register');
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const ctx = browser.defaultBrowserContext();
+await ctx.overridePermissions(server.url.origin, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']);
+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() === x || 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 = 8000) => { for (let i = 0; i < ms / 200; i++) { if ((await text()).includes(x)) return true; await sleep(200); } return false; };
+const freshSend = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Receive')?.click()); await sleep(300);
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Send')?.click());
+ await page.waitForSelector('textarea.send-dest', { timeout: 10000 });
+ await page.$eval('textarea.send-dest', (e) => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await sleep(300);
+ await page.focus('textarea.send-dest');
+};
+const paste = async (s) => { await page.evaluate((v) => navigator.clipboard.writeText(v), s); await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
+ for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /^(not now|skip|continue|later)$/i.test(b.textContent.trim()))?.click());
+ await sleep(400);
+ }
+ await sleep(3000);
+ const host = new URL(server.url.origin).hostname;
+
+ console.log('[a login lnurl pasted into Send]');
+ await freshSend();
+ await paste(loginLnurl);
+ check('the wallet asks first, naming the site', await waitText('Log in with Lightning') && (await text()).includes(host + ' is asking you to log in'), (await text()).slice(0, 200));
+ check('…says what it is about to do, and that nothing is paid', /key it keeps for this site alone/.test(await text()) && /No payment is made/.test(await text()));
+ check('…no amount field, no payment flow', !(await page.$('input[type=number]')));
+ check('nothing was sent to the site before the user agreed', logins.length === 0);
+ await click('log in');
+ check('the site accepts the signed challenge', await waitText('Logged in to ' + host, 8000), (await text()).slice(0, 300));
+ check('…it was told once, with a signature that verifies under the key', logins.length === 1 && logins[0].good && logins[0].k1 === k1, JSON.stringify(logins));
+ const key1 = logins[0].key;
+ await click('done');
+ check('Done returns to a blank Send form', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false) && (await page.$eval('textarea.send-dest', (e) => e.value)) === '');
+
+ console.log('[the same site again: the same key]');
+ await freshSend();
+ await paste(loginLnurl);
+ await waitText('Log in with Lightning'); await click('log in');
+ await waitText('Logged in to ' + host, 8000);
+ check('the wallet is the same user on the site next time', logins.length === 2 && logins[1].key === key1, JSON.stringify(logins.map((l) => l.key.slice(0, 12))));
+ await click('done');
+
+ console.log('[a site that says no]');
+ await freshSend();
+ await paste(closedLnurl);
+ check('a register link asks to create an account', await waitText('asking you to create an account'));
+ await click('create account');
+ check("the site's reason is shown", await waitText('registrations are closed', 8000), (await text()).slice(0, 300));
+ check('…with a way to try again, and back', (await text()).includes('Try again') && (await text()).includes('Back'));
+ await click('back');
+ check('Back leaves the login', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false));
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ check('run completed', false, e.stack);
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ a login lnurl logs in' : '\n❌ failures above');
+process.exit(ok ? 0 : 1);
diff --git a/tools/lnurl-auth-unit-test.js b/tools/lnurl-auth-unit-test.js
new file mode 100644
index 0000000..c9989be
--- /dev/null
+++ b/tools/lnurl-auth-unit-test.js
@@ -0,0 +1,63 @@
+// LNURL-auth (LUD-04/05) without a browser: a login lnurl is told from a
+// pay one; the linking key is the same for a domain every time and different
+// per domain; the signature over k1 verifies under the key sent.
+// Run: bun tools/lnurl-auth-unit-test.js
+import assert from 'node:assert/strict';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+import { encodeLnurl, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../src/lnurl.js';
+const k1 = 'a'.repeat(64);
+const loginUrl = 'https://stacker.news/api/lnauth?tag=login&k1=' + k1 + '&action=login';
+const a = parseLnurlAuth(encodeLnurl(loginUrl));
+assert.deepEqual(a, { url: loginUrl, domain: 'stacker.news', k1, action: 'login' });
+assert.equal(parseLnurlAuth('lightning:' + encodeLnurl(loginUrl).toUpperCase()).domain, 'stacker.news', 'a lightning: prefix and upper case are fine');
+assert.equal(parseLnurlAuth(encodeLnurl('https://stacker.news/api/lnauth?tag=login&k1=zz')), null, 'a k1 that is not 32 bytes is not a login');
+assert.equal(parseLnurlAuth(encodeLnurl('https://coinos.io/.well-known/lnurlp/adam')), null, 'a pay lnurl is not a login');
+assert.equal(parseLnurlAuth('[email protected]'), null);
+assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=register'))).action, 'register');
+assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=what'))).action, 'login', 'an unknown action is a login');
+const master = HDKey.fromMasterSeed(mnemonicToSeedSync('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'));
+const k = lnurlAuthKey(master, 'stacker.news');
+assert.equal(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'stacker.news').publicKey), 'the same key for a domain every time');
+assert.notEqual(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'coinos.io').publicKey), 'another domain, another key');
+assert.equal(k.publicKey.length, 33, 'a compressed public key');
+assert.notEqual(bytesToHex(lnurlAuthKey(HDKey.fromMasterSeed(mnemonicToSeedSync('zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong')), 'stacker.news').publicKey), bytesToHex(k.publicKey), 'another wallet, another user');
+// the callback, with a server that checks the signature the way theirs does
+let got = null;
+globalThis.fetch = async (url) => {
+ const u = new URL(url);
+ got = { sig: u.searchParams.get('sig'), key: u.searchParams.get('key'), k1: u.searchParams.get('k1'), tag: u.searchParams.get('tag') };
+ const okSig = secp256k1.verify(secp256k1.Signature.fromDER(got.sig), hexToBytes(got.k1), hexToBytes(got.key));
+ return new Response(JSON.stringify(okSig ? { status: 'OK' } : { status: 'ERROR', reason: 'bad sig' }), { headers: { 'content-type': 'application/json' } });
+};
+const r = await lnurlAuthLogin(a, k);
+assert.equal(got.tag, 'login'); assert.equal(got.k1, k1);
+assert.equal(got.key, bytesToHex(k.publicKey), 'the key sent is the linking key');
+assert.equal(r.key, got.key);
+assert.match(got.sig, /^30[0-9a-f]+$/, 'a DER signature');
+globalThis.fetch = async () => new Response(JSON.stringify({ status: 'ERROR', reason: 'no such user' }), { headers: { 'content-type': 'application/json' } });
+await assert.rejects(lnurlAuthLogin(a, k), /no such user/, "the server's reason is the error");
+console.log('✓ lnurl-auth: a login is told from a payment, one key per site per wallet, the signature verifies, the server has the last word');
+
+// ---- LNURL-withdraw (LUD-03) ----
+const { parseZapTarget, withdrawParamsOf, fetchWithdrawParams, submitWithdraw, fetchPayParams } = await import('../src/lnurl.js');
+const w = parseZapTarget('lnurlw://pay.example.com/withdraw?k=1');
+assert.equal(w.url, 'https://pay.example.com/withdraw?k=1', 'lnurlw:// is the URL over https');
+assert.equal(parseZapTarget('LIGHTNING:lnurlp://pay.example.com/.well-known/lnurlp/bob').url, 'https://pay.example.com/.well-known/lnurlp/bob');
+assert.equal(parseZapTarget('lnurlw://abc.onion/w').url, 'http://abc.onion/w', 'an onion over http');
+const terms = { tag: 'withdrawRequest', callback: 'https://pay.example.com/cb', k1: 'k1k1', minWithdrawable: 1000, maxWithdrawable: 50000, defaultDescription: 'faucet' };
+assert.deepEqual(withdrawParamsOf(terms), { callback: 'https://pay.example.com/cb', k1: 'k1k1', minMsat: 1000, maxMsat: 50000, description: 'faucet', domain: 'pay.example.com' });
+assert.equal(withdrawParamsOf({ tag: 'payRequest', callback: 'x' }), null);
+assert.equal(withdrawParamsOf({ ...terms, maxWithdrawable: 0 }), null, 'nothing to withdraw is no withdraw');
+globalThis.fetch = async () => Response.json(terms);
+assert.deepEqual(await fetchWithdrawParams('https://pay.example.com/w'), withdrawParamsOf(terms));
+await assert.rejects(fetchPayParams('https://pay.example.com/w'), (e) => e.withdraw && e.withdraw.k1 === 'k1k1', 'a pay fetch that finds a withdraw says so');
+let cb = null;
+globalThis.fetch = async (url) => { const u = new URL(url); cb = { path: u.pathname, k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }; return Response.json({ status: 'OK' }); };
+assert.equal(await submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), true);
+assert.deepEqual(cb, { path: '/cb', k1: 'k1k1', pr: 'lnbc1invoice' }, 'the callback gets k1 and the invoice');
+globalThis.fetch = async () => Response.json({ status: 'ERROR', reason: 'already claimed' });
+await assert.rejects(submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), /already claimed/);
+console.log('✓ lnurl-withdraw: lnurlw:// and lnurlp:// schemes, the terms, a pay fetch that finds a withdraw, the callback, the refusal');
diff --git a/tools/lnurl-withdraw-test.js b/tools/lnurl-withdraw-test.js
new file mode 100644
index 0000000..cc1b152
--- /dev/null
+++ b/tools/lnurl-withdraw-test.js
@@ -0,0 +1,103 @@
+// LNURL-withdraw: a site's withdraw lnurl pasted into Send is an offer to
+// PAY us — the wallet shows their range, makes an invoice for the amount
+// chosen, hands it to their callback and watches for the money. The Ark
+// side is faked (the invoice hook); the site is a stand-in on the test
+// server. Run: bun tools/lnurl-withdraw-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { encodeLnurl } from '../src/lnurl.js';
+
+const app = await Bun.file('src/app.js').text();
+const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import { zapsFeature } from './src/features/zaps.js';
+${domHelper}
+const ui = { screen: 'wallet', tab: 'send', send: { recipients: [{ address: '', amount: '' }] } };
+let watch = null, cancelled = [], invoices = 0;
+const wallet = { watchOnly: false, loadFeatureState: (k, d) => d, saveFeatureState: () => {} };
+const ctx = {
+ h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m),
+ blankSend: () => ({ recipients: [{ address: '', amount: '' }] }),
+ fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'),
+ parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats',
+ amountField: (sat) => String(sat),
+ hook(name, ...a) {
+ if (name === 'canLnPay') return true;
+ if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); }
+ if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; }
+ if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; }
+ if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); }
+ return null;
+ },
+};
+const feature = zapsFeature(ctx); window.__feature = feature;
+function render() { document.querySelector('#app').replaceChildren(feature.sendView() || h('div', { id: 'closed' }, 'closed')); }
+window.test = { paste: (s) => feature.matchSendText(s, false), settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, ui };
+render();
+`;
+const bundle = await Bun.build({
+ entrypoints: ['lnw-test-entry'], target: 'browser',
+ plugins: [{ name: 'lnw-test', setup(build) {
+ build.onResolve({ filter: /^lnw-test-entry$/ }, () => ({ path: 'entry', namespace: 'lnw-test' }));
+ build.onLoad({ filter: /.*/, namespace: 'lnw-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const css = await Bun.file('src/style.css').text();
+const js = await bundle.outputs[0].text();
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`;
+const claims = [];
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname === '/withdraw') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'deadbeef', minWithdrawable: 100000, maxWithdrawable: 5000000, defaultDescription: 'Faucet drip' });
+ if (u.pathname === '/withdraw-fixed') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'fixed1', minWithdrawable: 21000, maxWithdrawable: 21000 });
+ if (u.pathname === '/withdraw-spent') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb-spent', k1: 'spent', minWithdrawable: 1000, maxWithdrawable: 1000 });
+ if (u.pathname === '/cb') { claims.push({ k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }); return Response.json({ status: 'OK' }); }
+ if (u.pathname === '/cb-spent') return Response.json({ status: 'ERROR', reason: 'this voucher was already used' });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const lnurl = (path) => encodeLnurl(server.url.origin + path);
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(e.message));
+const pause = (ms) => new Promise((r) => setTimeout(r, ms));
+const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
+const waitText = async (re, ms = 5000) => { for (let i = 0; i < ms / 100; i++) { if (re.test(await text())) return true; await pause(100); } return false; };
+const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.textContent.trim() === x || n.textContent.trim().endsWith(' ' + x)); if (!b) return false; b.click(); return true; }, label);
+const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]);
+const host = new URL(server.url.origin).hostname;
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href);
+ await page.waitForSelector('#closed');
+ console.log('[a withdraw lnurl with a range]');
+ check('the paste is claimed', await page.evaluate((s) => test.paste(s), lnurl('/withdraw')));
+ check('their terms show: the site, the range, the description', await waitText(/Withdraw over Lightning/) && new RegExp(host + ' will pay you between 100 and 5,000 sats').test(await text()) && /Faucet drip/.test(await text()), await text());
+ check('the amount starts at their max', await page.$eval('input[type=number]', (i) => i.value) === '5000');
+ await type('input[type=number]', '9000'); await click('Withdraw'); await pause(100);
+ check('over their max: told the range, nothing sent', /Between 100 and 5,000 sats/.test(await text()) && claims.length === 0);
+ await type('input[type=number]', '1500'); await click('Withdraw');
+ check('an invoice for the amount is handed to their callback', await waitText(new RegExp(host + ' has your invoice for 1,500 sats')) && claims.length === 1 && claims[0].k1 === 'deadbeef' && claims[0].pr === 'lnbc1500n1testinvoice1', JSON.stringify(claims));
+ check('…and the wallet watches for the money', (await page.evaluate(() => test.watch)) === 'ln1');
+ await page.evaluate(() => test.settle(true));
+ check('paid: Received', await waitText(/Received 1,500 sats/) && !!(await page.$('.check-badge')));
+ await click('Done'); await pause(50);
+ check('Done closes it', !!(await page.$('#closed')));
+
+ console.log('[a fixed amount]');
+ await page.evaluate((s) => test.paste(s), lnurl('/withdraw-fixed'));
+ check('a fixed amount is stated, with no field', await waitText(new RegExp(host + ' will pay you 21 sats')) && !(await page.$('input[type=number]')));
+ await click('Withdraw');
+ check('withdrawn at that amount', await waitText(/has your invoice for 21 sats/) && claims[1].pr === 'lnbc21n1testinvoice2', JSON.stringify(claims));
+ await click('Back'); await pause(50);
+ check('Back before it is paid stops watching', (await page.evaluate(() => test.watch)) === null && !!(await page.$('#closed')));
+
+ console.log('[a site that refuses]');
+ await page.evaluate((s) => test.paste(s), lnurl('/withdraw-spent'));
+ await waitText(/will pay you 1 sats/); await click('Withdraw');
+ check("their reason is shown, with Try again", await waitText(/already used/) && /Try again/.test(await text()), await text());
+ check('no page errors', errors.length === 0, errors.join('; '));
+ console.log('\n✅ a withdraw lnurl pays us');
+} catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); }
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/nwc-budget-test.js b/tools/nwc-budget-test.js
new file mode 100644
index 0000000..780f5ea
--- /dev/null
+++ b/tools/nwc-budget-test.js
@@ -0,0 +1,52 @@
+// The NWC budget window: a UTC day, week (Monday) or month; what a
+// connection spent in the window on now, counting the app's counter and the
+// worker's unabsorbed spends; when it renews. Run: bun tools/nwc-budget-test.js
+import { budgetWindow, budgetRenewsAt, renewalPeriod, spentInWindow } from '../src/nwc-budget.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!ok) fails++; };
+
+// Wednesday 2026-10-14 23:30 UTC
+const T = Date.UTC(2026, 9, 14, 23, 30);
+check('day window is the UTC date', budgetWindow('day', T) === '2026-10-14', budgetWindow('day', T));
+check('day: half an hour later is the next day', budgetWindow('day', T + 3600_000) === '2026-10-15');
+check('week window is keyed by its Monday', budgetWindow('week', T) === 'W2026-10-12', budgetWindow('week', T));
+check('week: Sunday night is still that week', budgetWindow('week', Date.UTC(2026, 9, 18, 23, 59)) === 'W2026-10-12');
+check('week: Monday 00:00 starts the next', budgetWindow('week', Date.UTC(2026, 9, 19, 0, 0)) === 'W2026-10-19');
+check('month window is the UTC month', budgetWindow('month', T) === '2026-10');
+check('month: Nov 1 00:00 is the next', budgetWindow('month', Date.UTC(2026, 10, 1)) === '2026-11');
+check('unknown period counts as a day', budgetWindow(undefined, T) === '2026-10-14');
+
+check('day renews at next midnight', budgetRenewsAt('day', T) === Date.UTC(2026, 9, 15) / 1000);
+check('week renews next Monday', budgetRenewsAt('week', T) === Date.UTC(2026, 9, 19) / 1000);
+check('month renews on the 1st', budgetRenewsAt('month', T) === Date.UTC(2026, 10, 1) / 1000);
+check('December rolls into January', budgetRenewsAt('month', Date.UTC(2026, 11, 20)) === Date.UTC(2027, 0, 1) / 1000);
+check('NIP-47 period words', renewalPeriod('day') === 'daily' && renewalPeriod('week') === 'weekly' && renewalPeriod('month') === 'monthly' && renewalPeriod(undefined) === 'daily');
+
+console.log('\n[spent in the window]');
+const conn = { id: 'c1', dailySat: 10000, period: 'month', spentDate: '2026-10', spentToday: 3000 };
+{
+ const r = spentInWindow(conn, [], T);
+ check('the app counter counts when stamped with this window', r.spent === 3000 && r.left === 7000, JSON.stringify(r));
+}
+{
+ const r = spentInWindow({ ...conn, spentDate: '2026-09' }, [], T);
+ check('a counter from last month does not', r.spent === 0 && r.left === 10000);
+}
+{
+ const spends = [
+ { connId: 'c1', amountSat: 500, feeSat: 2, ts: Date.UTC(2026, 9, 2) }, // this month, unabsorbed
+ { connId: 'c1', amountSat: 400, feeSat: 0, ts: Date.UTC(2026, 9, 3), absorbed: true }, // already in the counter
+ { connId: 'c1', amountSat: 900, feeSat: 0, ts: Date.UTC(2026, 8, 30) }, // last month
+ { connId: 'c2', amountSat: 800, feeSat: 0, ts: Date.UTC(2026, 9, 5) }, // another connection
+ ];
+ const r = spentInWindow(conn, spends, T);
+ check('the worker adds only its own, unabsorbed spends from this window', r.spent === 3502, String(r.spent));
+}
+{
+ const r = spentInWindow({ id: 'c1', dailySat: 100, spentDate: '2026-10-14', spentToday: 80 }, [{ connId: 'c1', amountSat: 30, ts: T - 60_000 }], T);
+ check('a day budget, over: left is 0 not negative', r.spent === 110 && r.left === 0);
+}
+
+console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ budget windows behave');
+process.exit(fails ? 1 : 0);
diff --git a/tools/nwc-settings-ui-test.js b/tools/nwc-settings-ui-test.js
new file mode 100644
index 0000000..b88f9f7
--- /dev/null
+++ b/tools/nwc-settings-ui-test.js
@@ -0,0 +1,80 @@
+// Settings → Nostr → Nostr Wallet Connect: a connection is made with a
+// budget that resets every day, week or month; the card says so, and
+// Revoke keeps it (greyed out of the list) answering for a while.
+// Run: bun tools/nwc-settings-ui-test.js (needs the regtest ark stack on :3535 for the card to show)
+import puppeteer from './puppeteer.js';
+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 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'] });
+await browser.defaultBrowserContext().overridePermissions(server.url.origin, ['notifications']);
+const page = await browser.newPage();
+// a headless Chrome has no push service: make notifications look allowed so
+// the connection is created (the app creates it anyway when permission is
+// granted and the plumbing fails), without the real notifier in the picture
+await page.evaluateOnNewDocument(() => {
+ try { Object.defineProperty(Notification, 'permission', { get: () => 'granted' }); Notification.requestPermission = async () => 'granted'; } catch {}
+ // no service worker in this build: 'ready' would never resolve and the
+ // Create button would hang on it. Stand in one whose push subscribe fails
+ // the way a browser without a push service does.
+ try {
+ Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve({
+ pushManager: { subscribe: async () => { throw new Error('Registration failed - push service error'); }, getSubscription: async () => null },
+ showNotification: async () => {},
+ }) });
+ } catch {}
+ const f = window.fetch;
+ window.fetch = (u, o) => (String(u).includes('nwcpush') ? Promise.reject(new Error('no notifier in the test')) : f(u, o));
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const logs = [];
+page.on('console', (m) => logs.push(m.text()));
+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 text = () => page.evaluate(() => document.body.innerText);
+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');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800);
+ check('the Wallet Connect card is there', await waitText('nostr wallet connect', 15000));
+ await click('add a connection'); await sleep(400);
+ check('the form offers a budget period', await page.evaluate(() => { const s = document.querySelector('select.nwc-period'); return !!s && [...s.options].map((o) => o.value).join() === 'day,week,month' && s.value === 'day'; }));
+ check('…and says when it resets', (await text()).includes('midnight UTC'));
+ await page.type('input[placeholder*="App name"]', 'PullCap');
+ await page.evaluate(() => { const s = document.querySelector('select.nwc-period'); s.value = 'month'; s.dispatchEvent(new Event('change', { bubbles: true })); });
+ await page.evaluate(() => { const i = [...document.querySelectorAll('label.field')].find((l) => l.textContent.includes('Budget (sats)')).querySelector('input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.evaluate(() => { const i = [...document.querySelectorAll('label.field')].find((l) => l.textContent.includes('Budget (sats)')).querySelector('input'); i.value = '21000'; i.dispatchEvent(new Event('input', { bubbles: true })); });
+ await click('create connection');
+ const created = await waitText('pullcap', 10000);
+ if (!created) console.log('DEBUG env:', await page.evaluate(() => ({ push: 'PushManager' in window, sw: 'serviceWorker' in navigator, perm: Notification.permission, toast: document.querySelector('.toast, [class*=toast]')?.textContent })), '\nDEBUG logs:', logs.slice(-10).join('\n'));
+ check('the connection is created', created);
+ const line = (await text()).split('\n').find((l) => /\/payment/.test(l)) || '';
+ check('the card reads the budget as monthly', /21,000 a month · 0 spent this month/.test(line), line);
+ const st = await page.evaluate(() => { const k = Object.keys(localStorage).find((x) => x.endsWith(':nwc') || x.includes('nwc')); return k; });
+ const conn = await page.evaluate(() => { for (const k of Object.keys(localStorage)) { try { const v = JSON.parse(localStorage.getItem(k)); const c = v && v.conns && v.conns[0]; if (c && c.name === 'PullCap') return c; } catch {} } return null; });
+ check('stored with period month and the budget', conn && conn.period === 'month' && conn.dailySat === 21000, JSON.stringify(conn && { period: conn.period, dailySat: conn.dailySat }));
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.linklike')].find((n) => n.textContent.trim() === 'Revoke'); e.click(); }); await sleep(600);
+ const after = await page.evaluate(() => { for (const k of Object.keys(localStorage)) { try { const v = JSON.parse(localStorage.getItem(k)); const c = v && v.conns && v.conns[0]; if (c && c.name === 'PullCap') return c; } catch {} } return null; });
+ check('Revoke dates the revocation', after && after.revoked === true && typeof after.revokedAt === 'number' && Date.now() - after.revokedAt < 60_000, JSON.stringify(after && { revoked: after.revoked, revokedAt: after.revokedAt }));
+ check('…and the connection leaves the list', await page.evaluate(() => ![...document.querySelectorAll('.linklike')].some((n) => n.textContent.trim() === 'Revoke') && ![...document.querySelectorAll('button')].some((b) => b.textContent.includes('Show connection string'))));
+ await page.screenshot({ path: process.env.SHOT || '/tmp/claude-1000/-home-adam-coinosv3/a8722639-d1b4-4c63-af48-8537d47913b1/scratchpad/nwc-settings.png' });
+ 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✅ budget periods in the settings card' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index 62248a4..e3bdb7b 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -239,5 +239,136 @@ console.log('\n[sibling record hygiene]');
check('stale record keeps its coin state', saved.wStale.mgr.vtxos.length === 1);
}
+
+console.log('\n[period budgets + get_budget]');
+{
+ const { budgetWindow } = await import('../src/nwc-budget.js');
+ // a monthly budget: the app's counter (mirrored, stamped with this month) plus the worker's own spends count
+ const rec = baseRec();
+ rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1500 }];
+ rec.spends = [{ connId: 'c1', amountSat: 490, feeSat: 0, ts: Date.now() }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const r = decryptReply(published[0]);
+ check('app counter + worker spends over the monthly budget → refused, naming the period', handled && r.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r.error.message), JSON.stringify(r.error));
+}
+{
+ const { budgetWindow } = await import('../src/nwc-budget.js');
+ const rec = baseRec();
+ // a counter from LAST month and a worker spend from last month: neither counts this month
+ const lastMonth = new Date(); lastMonth.setUTCMonth(lastMonth.getUTCMonth() - 1);
+ rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month', lastMonth.getTime()), spentToday: 1999 }];
+ rec.spends = [{ connId: 'c1', amountSat: 1999, feeSat: 0, ts: lastMonth.getTime() }];
+ rec.mgr.vtxos = []; // so the pay stops at "empty mirror" right after the budget check
+ const { deps, published } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('last month\'s spending does not count this month (budget passed, mirror empty → wake)', handled === false && published.length === 0);
+}
+{
+ const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js');
+ const rec = baseRec();
+ rec.connections = [{ ...conn, period: 'week', dailySat: 7000, spentDate: budgetWindow('week'), spentToday: 1000 }];
+ rec.spends = [{ connId: 'c1', amountSat: 200, feeSat: 1, ts: Date.now() }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('get_budget')) }, deps);
+ const r = decryptReply(published[0]);
+ check('get_budget: used, total, renews_at and the period, in msat', handled && r.result?.used_budget === 1201000 && r.result?.total_budget === 7000000 && r.result?.renewal_period === 'weekly' && r.result?.renews_at === budgetRenewsAt('week'), JSON.stringify(r));
+ const { deps: d2, published: p2, decryptReply: dr2 } = harness();
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info')) }, d2);
+ check('get_info lists get_budget', dr2(p2[0]).result.methods.includes('get_budget'));
+}
+
+console.log('\n[revoked connection]');
+{
+ const rec = baseRec();
+ rec.connections = [{ ...conn, revoked: true }];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ const r = decryptReply(published[0]);
+ check('a pay on a revoked connection is refused UNAUTHORIZED, not ignored', handled === true && r.error?.code === 'UNAUTHORIZED' && r.result_type === 'pay_invoice', JSON.stringify(r));
+ const { deps: d2, published: p2, decryptReply: dr2 } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance', {}, 'nip04')) }, d2);
+ check('…so is a balance ask, in the asker\'s scheme', dr2(p2[0]).error?.code === 'UNAUTHORIZED' && p2[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04');
+}
+
+console.log('\n[the mirror carries what the worker needs]');
+{
+ const { buildBg } = await import('../src/nwc-bg.js');
+ const keyFor = (chain, i) => `${chain}:${i}`.padEnd(8, '0');
+ const mgrState = {
+ nextKeyIndex: 40, nextLnRecvIndex: 0,
+ vtxos: [{ id: 'v1', keyIndex: 7, state: 'spendable' }, { id: 'v0', keyIndex: 3, state: 'spent' }],
+ scheduled: [{ unlockHash: 'u1', inputIds: ['v1'], outKeyIndex: 12 }],
+ actions: [{ type: 'refresh', step: 'submitted', outKeyIndex: 15 }, { type: 'refresh', step: 'done', outKeyIndex: 16 }],
+ };
+ const rec = buildBg({ ark: {}, mgrState, keyFor, connections: [{ ...conn, period: 'month', spentDate: '2026-10', spentToday: 77, revoked: true }] });
+ check('a standing order\'s output key travels', rec.keys['12'] === keyFor(3, 12));
+ check('a renewal in flight\'s output key travels', rec.keys['15'] === keyFor(3, 15));
+ check('a finished renewal\'s does not', !rec.keys['16']);
+ check('the spent coin\'s key does not, the live one\'s does', !rec.keys['3'] && !!rec.keys['7']);
+ const c = rec.connections[0];
+ check('period, the spent counter and the revocation ride along', c.period === 'month' && c.spentDate === '2026-10' && c.spentToday === 77 && c.revoked === true);
+ check('…and the budget period defaults to a day', buildBg({ ark: {}, mgrState, keyFor, connections: [conn] }).connections[0].period === 'day');
+}
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const m = ArkManager.mergeScheduled([{ unlockHash: 'a' }, { unlockHash: 'b' }], [{ unlockHash: 'b' }, { unlockHash: 'c' }, { inputIds: [] }]);
+ check('standing orders merge by unlock hash', m.map((s) => s.unlockHash).join() === 'a,b,c', JSON.stringify(m));
+}
+
+console.log('\n[tending renewals while closed]');
+{
+ const { tendRenewals } = await import('../src/nwc-respond.js');
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const calls = [];
+ const orig = { init: ArkManager.prototype.init, tip: ArkManager.prototype._tipMemo, drive: ArkManager.prototype._driveScheduled, ensure: ArkManager.prototype.ensureScheduledRefreshes, refresh: ArkManager.prototype._driveRefresh };
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); calls.push(['init', this.network]); return this; };
+ ArkManager.prototype._tipMemo = async function () { calls.push(['tip']); return 100; };
+ ArkManager.prototype._driveScheduled = async function () {
+ calls.push(['drive', (this.state.scheduled || []).length]);
+ // the round for one order ran: it is promoted — out of scheduled, into a refresh action in flight
+ const ran = (this.state.scheduled || []).find((s) => s.unlockHash === 'ran');
+ this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== ran);
+ if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash });
+ };
+ ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; };
+ ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; };
+ try {
+ const rec = baseRec();
+ rec.key4 = 'ff'.repeat(32);
+ rec.mgr.nextKeyIndex = 5;
+ rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window
+ rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }];
+ const saved = [];
+ const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} });
+ check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls));
+ check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1'));
+ check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done');
+ // a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next
+ calls.length = 0;
+ const rec5 = baseRec(); rec5.key4 = 'ff'.repeat(32); rec5.keys = { 1: 'aa' };
+ rec5.mgr.actions = [{ id: 'r2', type: 'refresh', step: 'issued' }, { id: 'r3', type: 'refresh', step: 'done' }, { id: 's1', type: 'send', step: 'created' }];
+ const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} });
+ check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls));
+
+ calls.length = 0;
+ const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} });
+ check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+
+ calls.length = 0;
+ const old = baseRec(); // no key4: a mirror from before invoices — can't sign for the mailbox
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w3', rec: old }], saveFn: async () => {} });
+ check('an old mirror is left alone', calls.length === 0);
+
+ calls.length = 0;
+ const rec4 = baseRec(); rec4.key4 = 'ff'.repeat(32);
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w4', rec: rec4 }], saveFn: async () => {}, deadline: Date.now() - 1 });
+ check('out of push budget → nothing started', calls.length === 0);
+ } finally {
+ Object.assign(ArkManager.prototype, { init: orig.init, _tipMemo: orig.tip, _driveScheduled: orig.drive, ensureScheduledRefreshes: orig.ensure, _driveRefresh: orig.refresh });
+ }
+}
+
console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ background responder behaves');
process.exit(fails ? 1 : 0);
diff --git a/tools/nwc-test.js b/tools/nwc-test.js
index 848b2c6..5b0dd10 100644
--- a/tools/nwc-test.js
+++ b/tools/nwc-test.js
@@ -13,12 +13,13 @@ const store = {};
globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>{store[k]=v;}, removeItem:k=>{delete store[k];} };
let subHandler = null;
+const subFilters = []; // every filter subscribed, in order
let priorReplies = [];
let claimOk = true;
const published = [];
// fake relay transport: capture the subscription handler and any replies
const nwcTransport = {
- subscribe: (relays, filter, on) => { subHandler = on; return () => { subHandler = null; }; },
+ subscribe: (relays, filter, on) => { subFilters.push(filter); subHandler = on; return () => { subHandler = null; }; },
publish: async (relays, evt) => { published.push(evt); return true; },
query: async () => priorReplies, // what other devices have answered (none, unless a test says so)
errGraceMs: 20, // the sibling-first hold, shortened for the harness
@@ -222,5 +223,68 @@ console.log('\n[device sync of connections]');
check('what we publish carries the connections', Array.isArray(ext.save().nwcConns));
}
+
+// ---------------------------------------------------------------------------
+console.log('\n[period budgets + get_budget]');
+{
+ const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js');
+ const st = JSON.parse(store['fs:nwc']);
+ const c = st.conns.find((x) => x.id === 'test1');
+ Object.assign(c, { period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1990 });
+ store['fs:nwc'] = JSON.stringify(st);
+ f.stop(); f.init(); priorReplies = []; claimOk = true;
+ const wasPaid = paid.length;
+ r = await request('pay_invoice', { invoice: 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g' });
+ check('21 sat over a monthly budget with 10 left → refused, naming the month', r?.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r?.error?.message || ''), JSON.stringify(r?.error));
+ check('nothing was paid', paid.length === wasPaid);
+ r = await request('get_budget', {});
+ check('get_budget reports the month', r?.result?.used_budget === 1990000 && r?.result?.total_budget === 2000000 && r?.result?.renewal_period === 'monthly' && r?.result?.renews_at === budgetRenewsAt('month'), JSON.stringify(r));
+ r = await request('get_info', {});
+ check('get_info lists get_budget', r?.result?.methods?.includes('get_budget'));
+ // last month's counter is not this month's
+ const st2 = JSON.parse(store['fs:nwc']);
+ const lm = new Date(); lm.setUTCMonth(lm.getUTCMonth() - 1);
+ Object.assign(st2.conns.find((x) => x.id === 'test1'), { spentDate: budgetWindow('month', lm.getTime()), spentToday: 1990 });
+ store['fs:nwc'] = JSON.stringify(st2);
+ f.stop(); f.init();
+ r = await request('get_budget', {});
+ check('a counter from last month reads as nothing spent', r?.result?.used_budget === 0, JSON.stringify(r?.result));
+ r = await request('pay_invoice', { invoice: INV21 }); // the mock seam pays 25 + 1 fee
+ check('…and a payment goes through, stamped with this month', !!r?.result?.preimage && JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentDate === budgetWindow('month') && JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentToday === 26, JSON.stringify(r?.error || r));
+}
+
+console.log('\n[revoked: refused out loud, for a while]');
+{
+ const st = JSON.parse(store['fs:nwc']);
+ const c = st.conns.find((x) => x.id === 'test1');
+ c.revoked = true; c.revokedAt = Date.now();
+ store['fs:nwc'] = JSON.stringify(st);
+ subFilters.length = 0;
+ f.stop(); f.init();
+ check('a lately revoked connection is still listened for', subFilters.some((x) => x.kinds?.[0] === 23194 && x['#p']?.[0] === conn.servicePk));
+ const wasPaid = paid.length;
+ r = await request('pay_invoice', { invoice: INV21 });
+ check('a pay is answered UNAUTHORIZED', r?.error?.code === 'UNAUTHORIZED' && r?.result_type === 'pay_invoice', JSON.stringify(r));
+ check('nothing was paid', paid.length === wasPaid);
+ r = await request('get_balance', {}, 'nip04');
+ check('a balance ask too, in nip04', r?.error?.code === 'UNAUTHORIZED' && r?.result_type === 'get_balance', JSON.stringify(r));
+ const n = published.filter((e) => e.kind === 13194 && e.pubkey === conn.servicePk).length;
+ f.stop(); f.init(); await new Promise((r) => setTimeout(r, 100));
+ check('no capability event is published for it', published.filter((e) => e.kind === 13194 && e.pubkey === conn.servicePk).length === n);
+ check('the revocation date travels in the snapshot', wallet.__ext.save().nwcConns.find((x) => x.id === 'test1').revokedAt === c.revokedAt);
+ // 31 days on: forgotten
+ const st2 = JSON.parse(store['fs:nwc']);
+ st2.conns.find((x) => x.id === 'test1').revokedAt = Date.now() - 31 * 86_400_000;
+ store['fs:nwc'] = JSON.stringify(st2);
+ subFilters.length = 0;
+ f.stop(); f.init();
+ check('after the grace period it is no longer listened for', !subFilters.some((x) => x.kinds?.[0] === 23194 && x['#p']?.[0] === conn.servicePk));
+ // a revocation dated on two devices keeps the earlier date
+ const ext = wallet.__ext;
+ const stale = { ...JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1'), revokedAt: Date.now() - 40 * 86_400_000 };
+ ext.load({ nwcConns: [stale] });
+ check('merge keeps the earlier revocation date', JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').revokedAt === stale.revokedAt);
+}
+
console.log(ok ? '\n✅ NWC service behaves' : '\n❌ failed');
process.exit(ok?0:1);
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-controls-test.js b/tools/pager-controls-test.js
new file mode 100644
index 0000000..a02591d
--- /dev/null
+++ b/tools/pager-controls-test.js
@@ -0,0 +1,114 @@
+// The media pager's buttons under a finger: the sound button mutes on ONE
+// tap (the browser's click and the pager's pending tap both clicked it, so
+// it toggled twice and a clip could not be muted — Adam, 2026-10-16), and
+// the reaction button offers the emoji sheet, over the pager, as the feed's
+// does; the emoji picked shows on the button, and a tap again takes it back.
+// Run: bun tools/pager-controls-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 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 = { tall: clip('tall', '180x320') };
+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: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/tall.webm') return new Response(Bun.file(CLIPS.tall), { 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 seeded = [post('a tall one ' + server.url.origin + '/tall.webm', 100),
+ ...Array.from({ length: 8 }, (_, 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', '--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; };
+const centre = (sel) => page.evaluate((s) => { const r = document.querySelector(s).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }, sel);
+const tap = async (sel) => { const c = await centre(sel); await page.touchscreen.tap(c.x, c.y); await sleep(450); }; // (past the pending tap's 120 ms)
+const sound = () => page.evaluate(() => { const v = document.querySelector('.mp-slide video.mp-media'); return { quiet: document.querySelector('.media-pager').classList.contains('quiet'), muted: v ? v.muted : null }; });
+const likeBtn = () => page.evaluate(() => { const b = document.querySelector('.mp-like'); return { on: b.classList.contains('on'), text: b.querySelector('.i').textContent.trim(), heart: !!b.querySelector('.i svg') }; });
+
+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 clip is in the feed', await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 20000));
+ await page.evaluate(() => document.querySelector('.notes-feed video.note-video').scrollIntoView({ block: 'center' }));
+ await sleep(500);
+ const at = await page.evaluate(() => { const b = document.querySelector('.notes-feed video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ await page.touchscreen.tap(at.x, at.y);
+ check('the clip opens in the pager', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide video.mp-media'), null, 6000));
+ await sleep(1000); // (a click within 800 ms of the touch that opened it is that touch's)
+ // the buttons sit in the details, up on a tap
+ await tap('.mp-slide video.mp-media');
+ check('a tap brings the details up', await waitFor(() => document.querySelector('.media-pager').classList.contains('show'), null, 2000));
+
+ console.log('\n[the sound button: one tap, one change]');
+ const s0 = await sound();
+ check('it starts loud', !s0.quiet && s0.muted === false, JSON.stringify(s0));
+ await tap('.mp-sound');
+ let s = await sound();
+ check('one tap mutes it — and it stays muted', s.quiet && s.muted === true, JSON.stringify(s));
+ await sleep(500); s = await sound();
+ check('…still muted half a second on', s.quiet && s.muted === true, JSON.stringify(s));
+ await tap('.mp-sound');
+ s = await sound();
+ check('a tap again unmutes', !s.quiet && s.muted === false, JSON.stringify(s));
+
+ console.log('\n[the reaction button: a menu of emoji, over the pager]');
+ check('the details are still up', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show')));
+ await tap('.mp-like');
+ check('a tap opens the emoji sheet', await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager .msg-sheet-emojis'), null, 3000));
+ check('…on top of the pager', await page.evaluate(() => { const b = document.querySelector('.msg-sheet-emojis button'); const r = b.getBoundingClientRect(); const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return !!hit && !!hit.closest('.msg-sheet-emojis'); }));
+ check('…with several to pick from, and a way to more', await page.evaluate(() => document.querySelectorAll('.msg-sheet-emojis button').length >= 5 && !!document.querySelector('.msg-sheet-emojis .more')));
+ check('the pager is still there beneath', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find((b) => b.textContent.trim() === '🔥').click());
+ check('picking 🔥 closes the sheet', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000));
+ check('…and the button shows 🔥, lit', await waitFor(() => { const b = document.querySelector('.mp-like'); return b.classList.contains('on') && b.querySelector('.i').textContent.trim() === '🔥'; }, null, 4000), JSON.stringify(await likeBtn()));
+ check('…still in the pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await tap('.mp-like');
+ check('a tap again takes the reaction back: the heart, unlit', await waitFor(() => { const b = document.querySelector('.mp-like'); return !b.classList.contains('on') && !!b.querySelector('.i svg'); }, null, 4000), JSON.stringify(await likeBtn()));
+ await tap('.mp-like');
+ await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000);
+ await page.keyboard.press('Escape');
+ check('Escape closes the sheet, not the pager', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 2000) && await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ 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 buttons under a finger' : '\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
new file mode 100644
index 0000000..34c89b6
--- /dev/null
+++ b/tools/pager-gone-picture-test.js
@@ -0,0 +1,72 @@
+// A picture whose host is down (its server answers 502, so neither a copy
+// 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';
+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;
+const PICS = { big1: png('2400x1800'), big3: png('2000x2000') };
+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 hits = [];
+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 === '/big2.png') { hits.push('big2'); return new Response('Bad Gateway', { status: 502 }); } // the host is down
+ const m = /^\/(big\d)\.png$/.exec(path);
+ if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300),
+ ...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);
+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 i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; }));
+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 feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000));
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await sleep(300);
+ 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: 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 is passed over' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js
new file mode 100644
index 0000000..42a5344
--- /dev/null
+++ b/tools/pager-loading-test.js
@@ -0,0 +1,82 @@
+// Exercise the production refill scheduler with delayed and sparse sources.
+// Run: bun tools/pager-loading-test.js
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import assert from 'node:assert/strict';
+
+const source = readFileSync(new URL('../src/features/messages.js', import.meta.url), 'utf8');
+const scheduler = source.slice(source.indexOf(' const PAGER_INITIAL ='), source.indexOf(' // Slides for feed items', source.indexOf(' const PAGER_INITIAL =')));
+function fixture(kind = 'images', grid = false) {
+ let clock = 100000, calls = 0, pending = null, count = 3;
+ const groups = Array.from({ length: 300 }, () => ({}));
+ const cache = { shown: 3, notes: Array(3), stopped: false };
+ const index = { until: 10000, images: new Map(), videos: new Map() };
+ const p = { kind, cur: { closest: () => groups[0] } };
+ const more = async () => {
+ calls++;
+ if (pending) await pending;
+ count += 10;
+ cache.shown += 10; cache.notes.length += 10;
+ index.until -= 100;
+ };
+ const src = { c: cache, more, done: () => false };
+ const ctx = vm.createContext({
+ 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 },
+ });
+ vm.runInContext(scheduler, ctx);
+ return {
+ ctx, p, src, cache, index, load: () => ctx.pagerLoadAhead(),
+ go: (i) => { p.cur = { closest: () => groups[i] }; },
+ advance: () => { clock += 11000; },
+ delay: () => { let resolve; pending = new Promise((r) => { resolve = r; }); return () => { pending = null; resolve(); }; },
+ count: () => count, calls: () => calls,
+ };
+}
+
+for (const grid of [false, true]) for (const kind of ['images', 'videos']) {
+ const f = fixture(kind, grid);
+ await f.load();
+ assert.equal(f.calls(), 3, 'opening starts loading immediately to at least 30 posts');
+ f.advance(); await f.load();
+ assert.equal(f.calls(), 3, 'a full buffer does not download forever');
+ f.go(29); f.advance();
+ const finish = f.delay();
+ const loading = f.load();
+ assert.equal(f.calls(), 4, 'refill starts well before the end');
+ f.go(0); await f.load();
+ assert.equal(f.calls(), 4, 'wrapping during a request does not start a concurrent one');
+ finish(); await loading;
+ assert.equal(f.count(), 53, 'refill continues after wrapping, keeping 20 beyond the furthest post');
+ console.log('✓ early loading and wrap refill:', grid ? 'grid' : 'feed/profile', kind);
+}
+{
+ const f = fixture();
+ f.src.more = async () => { f.cache.notes.length++; };
+ await f.load();
+ assert.equal(f.cache.notes.length, 11, 'sparse results are limited to eight requests per run');
+ f.advance(); await f.load();
+ assert.equal(f.cache.notes.length, 19, 'timer can resume sparse results from the first post');
+}
+{
+ const f = fixture();
+ let calls = 0;
+ f.src.more = async () => { calls++; };
+ await f.load(); await f.load();
+ assert.equal(calls, 1, 'an empty result backs off');
+ f.advance(); await f.load();
+ assert.equal(calls, 2, 'an empty source can retry');
+ f.src.done = () => true;
+ f.advance(); await f.load();
+ assert.equal(calls, 2, 'an exhausted source is not requested');
+}
+{
+ const f = fixture();
+ const finish = f.delay(), loading = f.load();
+ f.ctx.pager = { kind: 'videos' };
+ finish(); await loading;
+ assert.equal(f.calls(), 1, 'closing or switching pagers stops the old refill');
+}
+console.log('✓ sparse sources, backoff, exhaustion, and pager lifecycle');
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-sharpen-test.js b/tools/pager-sharpen-test.js
new file mode 100644
index 0000000..ea5c878
--- /dev/null
+++ b/tools/pager-sharpen-test.js
@@ -0,0 +1,78 @@
+// A picture in the media pager flies in as the feed's column copy (the one
+// the feed already decoded) and is sharpened to the original once its slide
+// comes to rest; only the slide at rest carries the zoom layer. (Originals
+// decoded and uploaded as they passed made a flight stutter — Adam,
+// 2026-10-16.) Run: bun tools/pager-sharpen-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;
+const PICS = { big1: png('2400x1800'), big2: png('1800x2400'), big3: png('2000x2000') };
+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 hits = [];
+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));
+ const m = /^\/(big\d)\.png$/.exec(path);
+ if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300),
+ ...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();
+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 i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; }));
+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 feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000));
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await sleep(300);
+ 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));
+ let s = await slides();
+ check('every slide carries the copy, not the original', s.length >= 3 && s.every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src)));
+ check('the slide on screen is the current one, alone with the zoom layer', s.filter((x) => x.cur).length === 1 && s.find((x) => x.cur).on && s.every((x) => (x.willChange === 'transform') === x.cur), JSON.stringify(s.map((x) => [x.cur, x.willChange])));
+ check('…sharpened to the original once 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), JSON.stringify((await slides()).map((x) => x.src)));
+ s = await slides();
+ check('the others stay on their copies', s.filter((x) => !x.cur).every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src)));
+ await page.keyboard.press('ArrowDown');
+ check('down: the next slide becomes current', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000));
+ check('…and is sharpened in its turn', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big2.png'); }, null, 5000), JSON.stringify((await slides()).map((x) => x.src)));
+ s = await slides();
+ check('only one slide has the zoom layer', s.filter((x) => x.willChange === 'transform').length === 1 && s.find((x) => x.willChange === 'transform').k === 'big2.png');
+ 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✅ pager slides fly as copies and sharpen at rest' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js
new file mode 100644
index 0000000..707aad6
--- /dev/null
+++ b/tools/pager-swipe-fly-test.js
@@ -0,0 +1,181 @@
+// 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 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';
+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;
+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 });
+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 (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/p' + n + '.png';
+const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [], content: 'picture post ' + (i + 1) + ' ' + U(i + 1) }, SK));
+
+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);
+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; };
+// 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 < 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
+// simulation takes a round trip per move on top, so a flick through it is
+// never faster than a stroll.
+const swipe = async (px, ms, steps = 6) => {
+ if (ms >= 100) {
+ await page.touchscreen.touchStart(195, 700);
+ for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(195, 700 - px * i / steps); await sleep(ms / steps); }
+ await page.touchscreen.touchEnd();
+ return;
+ }
+ await page.evaluate(([px, ms, steps]) => {
+ 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)] }));
+ // on a schedule from the start, not a pause after each move: a move's
+ // handling (the slides following the finger) takes its own time
+ const t0 = performance.now();
+ fire('touchstart', 700);
+ for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); fire('touchmove', 700 - px * i / steps); }
+ 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' });
+ 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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000));
+ 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, 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();
+ check('a 320px swipe over 130ms: the next post', (await at()) === 2, String(await at()));
+
+ 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 coasts round to the start]');
+ 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 comes round to the first post', before >= 7 && (await at()) === 1, String(await at()));
+ await swipe(-600, 80, 3); await settled();
+ check('…and a flick back up from the first comes round to the last', (await at()) === N, String(await at()));
+ await swipe(600, 80, 3); await settled();
+ check('…a flick down from the last, to the first again', (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 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(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');
+process.exit(ok ? 0 : 1);
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/pager-wheel-test.js b/tools/pager-wheel-test.js
index 1828f77..45a5115 100644
--- a/tools/pager-wheel-test.js
+++ b/tools/pager-wheel-test.js
@@ -94,6 +94,8 @@ try {
console.log('\n[a wheel notch is a step]');
await openPic('two pictures');
check('the picture opens in the pager', await resting('a1.png'), await curPic());
+ check('opening fills the media buffer without scrolling to the end', await waitFor(() => document.querySelectorAll('.mp-group').length === 15, null, 8000));
+ check('background loading keeps the first picture on screen', await curPic() === 'a1.png');
await page.mouse.move(640, 450);
await wheel(100);
check('a notch down: the next post\'s picture', await resting('b.png'), await curPic());
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 85aee86..aac681a 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);
@@ -125,8 +126,10 @@ try {
check('…landed on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture')));
await openPic('one picture');
check('opened again on the picture it closed on', await resting('b.png'), await curPic());
+ await tapPager(); await sleep(400);
+ check('a tap on the picture that opened it shows its details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
await tapPager(); await sleep(600);
- check('a tap on the picture that opened it closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…and the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
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 profile\'s pictures page through the pager' : '\n❌ failed');
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-pager-test.js b/tools/thread-pager-test.js
new file mode 100644
index 0000000..0f99742
--- /dev/null
+++ b/tools/thread-pager-test.js
@@ -0,0 +1,134 @@
+// A picture tapped on a thread page opens in the media pager over the
+// THREAD's pictures — the root's and its replies', in the page's order,
+// nobody else's — with the caption and controls a tap away, and closing it
+// leaves the thread on screen. (It opened alone, in the lightbox, before:
+// a tap on it closed it, with no caption or controls — Adam, 2026-10-16.)
+// Run: bun tools/thread-pager-test.js
+import puppeteer from './puppeteer.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;
+const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') };
+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 === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } });
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path);
+ if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk = SK, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const root = post('two pictures in the root ' + U('a1') + ' ' + U('a2'), 600);
+const replyWords = post('a reply with only words', 500, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]);
+const replyPic = post('a reply with a picture ' + U('b'), 400, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]);
+const feedNotes = [
+ post('someone else, newer, with a picture ' + U('x'), 100, SK2),
+ post('another post with a picture ' + U('d'), 200),
+ root,
+ ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 700 + i * 60)),
+];
+const replies = [replyWords, replyPic];
+const profiles = [finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Picture Poster' }) }, SK),
+ finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Reply Writer' }) }, SK2)];
+const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes: feedNotes, profiles, popUntil: now - 3600 });
+
+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 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 curPic = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
+ const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
+ return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
+});
+const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
+const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
+const openPicIn = async (scope, word, nth = 0) => {
+ await page.evaluate(([sc, w]) => { const r = [...document.querySelectorAll(sc)].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, [scope, word]);
+ await sleep(400);
+ const at = await page.evaluate(([sc, w, n]) => { const r = [...document.querySelectorAll(sc)].find((x) => x.textContent.includes(w)); const i = r.querySelectorAll('img.note-img')[n].getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, [scope, word, nth]);
+ await page.mouse.click(at.x, at.y);
+ await waitFor(() => !!document.querySelector('.media-pager, .lightbox'), null, 4000);
+};
+const threadRowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.thread-page [data-zap-post]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);
+
+try {
+ await page.setViewport({ width: 390, height: 844, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(([notes, replies, profiles]) => {
+ class FakeSocket extends EventTarget {
+ constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
+ setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
+ emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
+ message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
+ send(raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
+ if (m[0] !== 'REQ') return;
+ const [, id, f] = m; this.subs.add(id);
+ let evs = [];
+ const all = [...notes, ...replies];
+ if (f['#e']) evs = replies.filter((e) => e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1])));
+ else if (f.ids) evs = all.filter((e) => f.ids.includes(e.id));
+ else if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) && (!f.authors || f.authors.includes(e.pubkey))).slice(0, f.limit || 100);
+ if (f.kinds?.includes(0)) evs = profiles.filter((p) => !f.authors || f.authors.includes(p.pubkey));
+ setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
+ }
+ close() { this.readyState = 3; this.emit('close', new Event('close')); }
+ }
+ Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ window.WebSocket = FakeSocket;
+ }, [feedNotes, replies, profiles]);
+ await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' });
+ check('the feed shows the posts', await waitFor(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, null, 20000));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(300);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); (r.querySelector('.note-text') || r).click(); });
+ check('the thread opens', await waitFor(() => !!document.querySelector('.thread-page'), null, 5000));
+ check('…with its replies', await waitFor(() => document.body.innerText.includes('a reply with a picture'), null, 8000));
+ await sleep(500);
+
+ console.log('\n[a picture on a thread page]');
+ await openPicIn('.thread-page [data-zap-post]', 'two pictures in the root');
+ check('it opens in the media pager, not the lightbox', await page.evaluate(() => !!document.querySelector('.media-pager') && !document.querySelector('.lightbox')));
+ check('…on itself', await resting('a1.png'), await curPic());
+ const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop()));
+ check('the pager holds the thread\'s pictures only, root first then the replies', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png']), JSON.stringify(keys));
+ check('counter: first of two posts', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 2');
+ check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show')));
+ await tapPager(); await sleep(400);
+ check('a tap shows the caption and controls', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
+ check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Picture Poster/.test(s.textContent) && /two pictures in the root/.test(s.textContent); }));
+ await page.keyboard.press('ArrowRight');
+ check('right: the root\'s second picture', await resting('a2.png'), await curPic());
+ await page.keyboard.press('ArrowDown');
+ check('down: the reply\'s picture', await resting('b.png'), await curPic());
+ check('counter: second post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 2');
+ check('no loading slide (nothing more to fetch)', await page.$('.mp-tail') === null);
+ await page.keyboard.press('Escape');
+ check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ await sleep(600);
+ check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a reply with only words')));
+ check('…with the reply\'s post on screen', (await threadRowTop('a reply with a picture')) !== null && (await threadRowTop('a reply with a picture')) >= -10 && (await threadRowTop('a reply with a picture')) < 844, 'row top ' + (await threadRowTop('a reply with a picture')));
+
+ console.log('\n[a reply\'s picture, and a tap then another]');
+ await openPicIn('.thread-page [data-zap-post]', 'a reply with a picture');
+ check('opens on the reply\'s picture', await resting('b.png'), await curPic());
+ await tapPager(); await sleep(400);
+ check('a tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
+ await tapPager(); await sleep(600);
+ check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
+ check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-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✅ a thread\'s pictures page through the pager' : '\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/wn-only-device-test.js b/tools/wn-only-device-test.js
new file mode 100644
index 0000000..a91262b
--- /dev/null
+++ b/tools/wn-only-device-test.js
@@ -0,0 +1,129 @@
+// Settings → Nostr → Group invitations: a Marmot inviter (White Noise)
+// picks ONE of your published KeyPackages, so the group lands on one device
+// and nothing says which. The card lists the other keys of yours on the
+// relays and "Invite this device only" retires them (a kind 5 naming each:
+// NIP-09 `a` for the addressable 30443s, `e` for an old 443) and publishes
+// a fresh one of ours. A device whose key was retired by another sees the
+// deletion and stops putting a key back; the card says so there.
+// Run: bun tools/wn-only-device-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { 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 html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5341, fetch: (req) => { const p = new URL(req.url).pathname; if (p.startsWith('/punks') || p === '/verify-worker.js' || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); } 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();
+// a stand-in for every relay: the account's key packages and deletions are
+// whatever the test says, an EVENT is recorded and OK'd, everything else is empty
+await page.evaluateOnNewDocument(() => {
+ window.__published = []; window.__kps = []; window.__dels = []; window.__reqs = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ const answer = (id, evs) => setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) }));
+ }, 0);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const out = [];
+ for (const f of m.slice(2)) {
+ window.__reqs.push(f);
+ const kinds = f.kinds || [];
+ if ((kinds.includes(30443) || kinds.includes(443)) && f.authors) out.push(...window.__kps.filter((e) => kinds.includes(e.kind) && f.authors.includes(e.pubkey)), ...window.__published.filter((e) => e.kind === 30443 && f.authors.includes(e.pubkey)));
+ if (kinds.includes(5) && f.authors) out.push(...window.__dels.filter((e) => f.authors.includes(e.pubkey) && (!f['#a'] || e.tags.some((t) => t[0] === 'a' && f['#a'].includes(t[1])))));
+ }
+ answer(m[1], out);
+ return;
+ }
+ if (m[0] === 'EVENT' && m[1]) {
+ window.__published.push(m[1]);
+ // the relay honours the deletion: the keys it names are gone
+ if (m[1].kind === 5) { const ids = new Set(m[1].tags.filter((t) => t[0] === 'e').map((t) => t[1])); const as = new Set(m[1].tags.filter((t) => t[0] === 'a').map((t) => t[1])); window.__kps = window.__kps.filter((e) => !ids.has(e.id) && !as.has('30443:' + e.pubkey + ':' + (e.tags.find((t) => t[0] === 'd') || [])[1])); }
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
+ return;
+ }
+ } catch {}
+ return send(data);
+ };
+ 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 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, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; };
+const openNostrSettings = async () => {
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800);
+};
+const card = () => page.evaluate(() => { const c = document.querySelector('.wn-devices-card'); return c ? { state: c.querySelector('.wn-device-state')?.textContent || '', others: [...c.querySelectorAll('.wn-device')].map((e) => e.textContent), none: !!c.querySelector('.wn-no-others'), btn: !!c.querySelector('.wn-only-here') } : null; });
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.origin, { 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');
+ const SK = privateKeyFromSeedWords(mn), ME = getPublicKey(SK);
+ const now = Math.floor(Date.now() / 1000);
+ const slotA = 'a'.repeat(64), slotB = 'b'.repeat(64);
+ const kp = (slot, client, ago) => finalizeEvent({ kind: 30443, created_at: now - ago, tags: [['d', slot], ['client', client], ['mls_protocol_version', '1.0']], content: 'AAAA' }, SK);
+ const OTHERS = [kp(slotA, 'White Noise Android', 7 * 86400), kp(slotB, 'coinos', 2 * 86400), finalizeEvent({ kind: 443, created_at: now - 20 * 86400, tags: [['client', 'MDK/0.7.1']], content: 'AAAA' }, SK)];
+ await page.evaluateOnNewDocument((kps) => { window.__kps = kps; }, OTHERS);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+
+ console.log('\n[the card]');
+ await openNostrSettings();
+ check('Settings → Nostr has a Group invitations card', await waitFor(() => !!document.querySelector('.wn-devices-card'), 8000));
+ check('…that lists the three other keys, newest first, by app', await waitFor(() => document.querySelectorAll('.wn-devices-card .wn-device').length === 3, 10000) && (await card()).others[0].startsWith('coinos') && (await card()).others[1].startsWith('White Noise Android') && (await card()).others[2].startsWith('MDK/0.7.1'), JSON.stringify(await card()));
+ check('…and says whether this device\'s key is up yet (a seed wallet publishes it at boot, racing the card)', /can be invited|no invitation key/i.test((await card()).state), (await card()).state);
+
+ console.log('\n[Invite this device only]');
+ await page.evaluate(() => document.querySelector('.wn-only-here').click());
+ check('a kind 5 goes out', await waitFor(() => window.__published.some((e) => e.kind === 5), 15000));
+ const del = await page.evaluate(() => window.__published.find((e) => e.kind === 5));
+ check('…by the account, naming the two slots (a) and all three events (e)', del.pubkey === ME
+ && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotA) && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotB)
+ && OTHERS.every((o) => del.tags.some((t) => t[0] === 'e' && t[1] === o.id)), JSON.stringify(del.tags));
+ check('…and a fresh key of this device', await waitFor(() => window.__published.some((e) => e.kind === 30443), 10000));
+ const mine = await page.evaluate(() => window.__published.find((e) => e.kind === 30443));
+ check('…which the deletion does not name', !del.tags.some((t) => t[0] === 'a' && t[1].endsWith(':' + (mine.tags.find((x) => x[0] === 'd') || [])[1])));
+ check('the card now says this device can be invited, no others', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || '') && !!document.querySelector('.wn-devices-card .wn-no-others'), 10000), JSON.stringify(await card()));
+
+ console.log('\n[retired by another device]');
+ const mySlot = (mine.tags.find((x) => x[0] === 'd') || [])[1];
+ const retire = finalizeEvent({ kind: 5, created_at: now + 60, tags: [['a', '30443:' + ME + ':' + mySlot], ['e', mine.id], ['k', '30443']], content: 'group invitations go to one device' }, SK);
+ await page.evaluateOnNewDocument((d) => { window.__dels = [d]; }, retire);
+ // the device asks the relays about this a few times a day: make it due now
+ await page.evaluate(async ([k, pk]) => { const db = await new Promise((res, rej) => { const r = indexedDB.open('coinos-marmot'); r.onsuccess = () => res(r.result); r.onerror = rej; }); const key = k + ':' + pk + '|dev'; const st = db.transaction('kv', 'readwrite').objectStore('kv'); const v = await new Promise((res) => { const g = st.get(key); g.onsuccess = () => res(g.result); }); const o = typeof v === 'string' ? JSON.parse(v) : v; o.retireCheckedAt = 0; await new Promise((res) => { const p = st.put(typeof v === 'string' ? JSON.stringify(o) : o, key); p.onsuccess = res; }); }, [base, ME]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await openNostrSettings();
+ check('the card says this device no longer takes invitations', await waitFor(() => /no longer takes/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 12000), (await card())?.state);
+ await sleep(1500);
+ check('…and no key of this device was put back', !(await page.evaluate(() => window.__published.some((e) => e.kind === 30443))));
+ await page.evaluate(() => document.querySelector('.wn-only-here').click());
+ check('"Invite this device only" here takes the invitations back: a fresh key', await waitFor(() => window.__published.some((e) => e.kind === 30443), 12000));
+ check('…and the card says it can be invited again', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 10000), (await card())?.state);
+ 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✅ one device takes the group invitations' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/vmedia/server.js b/vmedia/server.js
index b2f5bae..0f11200 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
@@ -74,6 +77,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 +137,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`;
@@ -349,7 +375,7 @@ 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');
+ 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';
@@ -357,8 +383,8 @@ export async function handle(req) {
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);