diff options
72 files changed, 5211 insertions, 3909 deletions
@@ -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). @@ -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); @@ -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); |
