aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md1
-rw-r--r--README.md22
-rw-r--r--blossom/compose-blossom.yml23
-rw-r--r--blossom/server.js281
-rw-r--r--bridge/.gitignore2
-rw-r--r--bridge/config.example.json12
-rw-r--r--bridge/ln.js116
-rw-r--r--bridge/server.js373
-rw-r--r--build.js51
-rw-r--r--bun.lock3
-rw-r--r--fdroid/io.coinos.fdroid.yml11
-rw-r--r--feed/note-page.js12
-rw-r--r--feed/profile-page.js8
-rw-r--r--names/compose-libretranslate.yml26
-rw-r--r--names/ownership.js10
-rw-r--r--names/preview.js133
-rw-r--r--names/server.js1840
-rw-r--r--names/translate.js123
-rw-r--r--nwcpush/.gitignore2
-rw-r--r--nwcpush/config.example.json8
-rw-r--r--nwcpush/server.js529
-rw-r--r--package.json1
-rw-r--r--src/api.js11
-rw-r--r--src/app.js382
-rw-r--r--src/ark/manager.js414
-rw-r--r--src/ark/proto.js4
-rw-r--r--src/ark/validate.js8
-rw-r--r--src/assets/zap.mp3.base64.txt2
-rw-r--r--src/calls.js464
-rw-r--r--src/cordn-client.js6
-rw-r--r--src/dm-inbox.js27
-rw-r--r--src/features/ark.js702
-rw-r--r--src/features/gifts-wallet.js4
-rw-r--r--src/features/gifts.js167
-rw-r--r--src/features/hats.js43
-rw-r--r--src/features/messages.js4284
-rw-r--r--src/features/names.js117
-rw-r--r--src/features/nostrlogin.js56
-rw-r--r--src/features/nwc.js156
-rw-r--r--src/features/zaps.js172
-rw-r--r--src/feed-cache.js4
-rw-r--r--src/hls-player.js20
-rw-r--r--src/i18n.js255
-rw-r--r--src/image-thumbnails.js19
-rw-r--r--src/lang-guess.js10
-rw-r--r--src/live.js101
-rw-r--r--src/lnurl.js89
-rw-r--r--src/locales/ar.json30
-rw-r--r--src/locales/bn.json30
-rw-r--r--src/locales/de.json32
-rw-r--r--src/locales/es.json32
-rw-r--r--src/locales/fr.json32
-rw-r--r--src/locales/hi.json30
-rw-r--r--src/locales/id.json30
-rw-r--r--src/locales/it.json40
-rw-r--r--src/locales/ja.json32
-rw-r--r--src/locales/ko.json32
-rw-r--r--src/locales/mr.json30
-rw-r--r--src/locales/pt.json32
-rw-r--r--src/locales/ru.json32
-rw-r--r--src/locales/sw.json30
-rw-r--r--src/locales/ta.json30
-rw-r--r--src/locales/te.json30
-rw-r--r--src/locales/tr.json32
-rw-r--r--src/locales/ur.json30
-rw-r--r--src/locales/vi.json30
-rw-r--r--src/locales/zh.json32
-rw-r--r--src/marmot-client.js2
-rw-r--r--src/media-servers.js27
-rw-r--r--src/media-timeline.js140
-rw-r--r--src/media-upload.js72
-rw-r--r--src/negentropy.js197
-rw-r--r--src/nostr-login.js42
-rw-r--r--src/nostr.js158
-rw-r--r--src/nwc-bg.js91
-rw-r--r--src/nwc-budget.js54
-rw-r--r--src/nwc-lock.js18
-rw-r--r--src/nwc-respond.js439
-rw-r--r--src/style.css322
-rw-r--r--src/sw-nwc.js233
-rw-r--r--src/thread-prefetch.js8
-rw-r--r--src/video-compress.js63
-rw-r--r--static/docs/index.html182
-rw-r--r--static/og-image.pngbin0 -> 122763 bytes
-rw-r--r--tools/amber-dm-upgrade-test.js4
-rw-r--r--tools/amber-login-test.js4
-rw-r--r--tools/amount-field-unit-test.js16
-rw-r--r--tools/ark-htlc-orphan.test.js33
-rw-r--r--tools/ark-offline-init.test.js22
-rw-r--r--tools/ark/bg-renewal-test.js135
-rw-r--r--tools/ark/cooperative-exit-ui-test.js25
-rw-r--r--tools/ark/custody-foreign-test.js102
-rw-r--r--tools/ark/custody-test.js151
-rw-r--r--tools/ark/custody-ui-test.js136
-rw-r--r--tools/ark/offboard-split-test.js128
-rw-r--r--tools/ark/refresh-test.js9
-rw-r--r--tools/call-test.js237
-rw-r--r--tools/call-wake-test.js214
-rw-r--r--tools/composer-media-test.js21
-rw-r--r--tools/deepl-translate.test.js38
-rw-r--r--tools/feed-appetite-test.js159
-rw-r--r--tools/feed-boot-chrome-test.js113
-rw-r--r--tools/feed-conversations-test.js134
-rw-r--r--tools/feed-media-cache-test.js4
-rw-r--r--tools/feed-media-views-test.js28
-rw-r--r--tools/feed-momentum-test.js48
-rw-r--r--tools/feed-new-posts-test.js20
-rw-r--r--tools/feed-picture-pager-test.js7
-rw-r--r--tools/feed-video-pager-test.js5
-rw-r--r--tools/feed-wheel-reverse-test.js87
-rw-r--r--tools/fullscreen-notice-test.js2
-rw-r--r--tools/gift-from-test.js71
-rw-r--r--tools/gift-profile-setup-test.js96
-rw-r--r--tools/go-live-test.js289
-rw-r--r--tools/grid-early-player-test.js138
-rw-r--r--tools/history-export-test.js81
-rw-r--r--tools/hls-upload-test.js92
-rw-r--r--tools/image-thumbnails-test.js19
-rw-r--r--tools/kind0-overwrite-test.js141
-rw-r--r--tools/live-chat-actions-test.js74
-rw-r--r--tools/live-reactions-test.js13
-rw-r--r--tools/live-stream-test.js310
-rw-r--r--tools/ln-pay-double-wake.test.js74
-rw-r--r--tools/ln-receive-twin-test.js14
-rw-r--r--tools/lnurl-auth-test.js102
-rw-r--r--tools/lnurl-auth-unit-test.js63
-rw-r--r--tools/lnurl-withdraw-test.js103
-rw-r--r--tools/media-grid-dups-test.js169
-rw-r--r--tools/media-grid-order-test.js128
-rw-r--r--tools/media-moments-feed-test.js193
-rw-r--r--tools/media-servers-test.js52
-rw-r--r--tools/media-timeline-test.js80
-rw-r--r--tools/media-upload-identity-test.js63
-rw-r--r--tools/names-invoice-expiry-test.js70
-rw-r--r--tools/negentropy-sync-test.js159
-rw-r--r--tools/negentropy-test.js64
-rw-r--r--tools/notif-faces-test.js6
-rw-r--r--tools/nwc-budget-test.js52
-rw-r--r--tools/nwc-settings-ui-test.js80
-rw-r--r--tools/nwc-sw-test.js326
-rw-r--r--tools/nwc-test.js66
-rw-r--r--tools/pager-caption-test.js138
-rw-r--r--tools/pager-close-test.js2
-rw-r--r--tools/pager-controls-test.js114
-rw-r--r--tools/pager-eager-test.js22
-rw-r--r--tools/pager-first-frame-test.js126
-rw-r--r--tools/pager-gone-picture-test.js72
-rw-r--r--tools/pager-loading-test.js2
-rw-r--r--tools/pager-no-poster-test.js125
-rw-r--r--tools/pager-prime-test.js133
-rw-r--r--tools/pager-ready-gate-test.js153
-rw-r--r--tools/pager-rotate-test.js2
-rw-r--r--tools/pager-seek-hide-test.js155
-rw-r--r--tools/pager-sharpen-test.js78
-rw-r--r--tools/pager-swipe-fly-test.js185
-rw-r--r--tools/pager-tail-test.js2
-rw-r--r--tools/pager-tap-slop-test.js1
-rw-r--r--tools/picture-pager-ready-test.js102
-rw-r--r--tools/post-edit-test.js1
-rw-r--r--tools/profile-pager-test.js20
-rw-r--r--tools/profile-publish-race-test.js111
-rw-r--r--tools/push-resubscribe-test.js103
-rw-r--r--tools/quote-pager-test.js1
-rw-r--r--tools/quoted-clip-fullscreen-test.js92
-rw-r--r--tools/receive-batch-fold-test.js20
-rw-r--r--tools/registry-ownership-test.js18
-rw-r--r--tools/relay-link-test.js66
-rw-r--r--tools/resume-wake-test.js111
-rw-r--r--tools/safe-boot-test.js77
-rw-r--r--tools/scheduled-post-test.js99
-rw-r--r--tools/signer-resume-test.js140
-rw-r--r--tools/sw-push-quota.test.js89
-rw-r--r--tools/sync-blob-test.js10
-rw-r--r--tools/thread-instant-test.js158
-rw-r--r--tools/thread-pager-test.js134
-rw-r--r--tools/thread-prefetch-test.js10
-rw-r--r--tools/thread-reply-test.js21
-rw-r--r--tools/video-events-test.js2
-rw-r--r--tools/video-fallback-test.js6
-rw-r--r--tools/video-gone-test.js129
-rw-r--r--tools/video-hls-play-test.js133
-rw-r--r--tools/video-tap-test.js40
-rw-r--r--tools/video-tile-poster-test.js40
-rw-r--r--tools/vmedia-hls-test.js84
-rw-r--r--tools/zap-feedback-test.js7
-rw-r--r--tools/zap-signer-test.js43
-rw-r--r--vmedia/server.js231
187 files changed, 17394 insertions, 4864 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6519e54..15f1c65 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,3 +4,4 @@
- Deploy only the requested changes; preserve unrelated work in the shared workspace. Use an isolated release worktree when necessary.
- Production deploys through `git push prod HEAD:prod` (`prod` points to `cs:coinosv3.git`). The remote post-receive hook builds and publishes the site at https://v3.coinos.io. Verify the live assets and matching service-worker version after deployment.
- Keep `master` and `prod` identical (user's instruction, 2026-10-01). Build release worktrees from `master` (after `git fetch prod` and checking `prod/prod` is not ahead). After the prod push succeeds, fast-forward `master` to the deployed commit and push it to `origin`: `git push prod HEAD:prod && git push origin HEAD:master`. If the shared checkout has uncommitted work on `master`, move the ref with `git update-ref` and carry the working-tree edits over with a three-way merge per file (`git merge-file`); never stash or reset them away.
+- Server-side services (names registrar, nwcpush, blossom, bridge; feed and vmedia are moving there too) live in the `coinosv3-server` repository (clone at `~/coinosv3-server`, remotes `prod` = `cs:coinosv3-server.git`, `origin` = GitHub). Deploy them with `git push prod HEAD:prod && git push origin HEAD:master` from that clone; its post-receive hook restarts only the services whose files changed. Never scp files into `~/arkswap` or `~/coinosv3-server` on cs (user's instruction, relayed 2026-10-16).
diff --git a/README.md b/README.md
index 4593506..077b71a 100644
--- a/README.md
+++ b/README.md
@@ -221,17 +221,25 @@ 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 and on Nostr via
+[gittr](https://gittr.space/npub1w27mc4aa6m0ufe3xs5z3m6qyr52gc0rglept7vqlwx4xeaf72tasnqz70r/coinosv3-server)): 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/build.js b/build.js
index f97907f..838e891 100644
--- a/build.js
+++ b/build.js
@@ -14,7 +14,7 @@ import { mkdir, readdir } from 'node:fs/promises';
const FAVICON = 'data:image/svg+xml,%3Csvg%20width%3D%2272%22%20height%3D%2272%22%20viewBox%3D%220%200%2072%2072%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%20%3Cpath%20d%3D%22M69.8824%2036C69.8824%2054.7127%2054.7127%2069.8824%2036%2069.8824C17.2873%2069.8824%202.11765%2054.7127%202.11765%2036C2.11765%2017.2873%2017.2873%202.11765%2036%202.11765C54.7127%202.11765%2069.8824%2017.2873%2069.8824%2036Z%22%20fill%3D%22white%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%204.23529C18.4568%204.23529%204.23529%2018.4568%204.23529%2036C4.23529%2053.5432%2018.4568%2067.7647%2036%2067.7647C53.5432%2067.7647%2067.7647%2053.5432%2067.7647%2036C67.7647%2018.4568%2053.5432%204.23529%2036%204.23529ZM0%2036C0%2016.1177%2016.1177%200%2036%200C55.8823%200%2072%2016.1177%2072%2036C72%2055.8823%2055.8823%2072%2036%2072C16.1177%2072%200%2055.8823%200%2036Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%2058.5882C48.4751%2058.5882%2058.5882%2048.4751%2058.5882%2036C58.5882%2023.5248%2048.4751%2013.4117%2036%2013.4117C23.5249%2013.4117%2013.4118%2023.5248%2013.4118%2036C13.4118%2048.4751%2023.5249%2058.5882%2036%2058.5882ZM36%2054C45.9411%2054%2054%2045.9411%2054%2036C54%2026.0589%2045.9411%2018%2036%2018C26.0589%2018%2018%2026.0589%2018%2036C18%2045.9411%2026.0589%2054%2036%2054Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36.0001%2022.8988C36.0001%2022.8988%2036%2022.8988%2036%2022.8988C28.7644%2022.8988%2022.8988%2028.7644%2022.8988%2036C22.8988%2043.2356%2028.7644%2049.1012%2036%2049.1012C36%2049.1012%2036.0001%2049.1012%2036.0001%2049.1012V22.8988Z%22%20fill%3D%22black%22/%3E%20%3C/svg%3E';
// Static assets copied verbatim into dist/ (icons referenced by the manifest).
-const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt'];
+const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'og-image.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt'];
const MANIFEST = {
id: '/',
@@ -86,6 +86,12 @@ self.addEventListener('fetch', (e) => {
// Feed snapshots have their own short HTTP lifetime; never freeze one in Cache Storage.
if (u.pathname === '/api/feed') return;
+ // Streams: a live playlist grows every two seconds (frozen at its first
+ // copy, the player stalled and failed), and video segments would fill the
+ // cache with what's already been watched.
+ if (u.pathname.startsWith('/live/') || u.pathname.startsWith('/vmedia/hls')) return;
+ // The developer docs are their own page, not the app: never the offline shell.
+ if (u.pathname === '/docs' || u.pathname.startsWith('/docs/')) return;
if (req.mode === 'navigate') {
// A note page comes back with its thread rendered into it (the server's
@@ -153,6 +159,15 @@ const SW_REGISTER =
`navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` +
`addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`;
+// Crash-loop guard. iOS kills a page that dies twice in a row while opening
+// ("A problem repeatedly occurred") and the wallet is locked out with nothing
+// to go on. Count page starts here, before the app runs; the app clears the
+// count once it has lived a while or is left cleanly (hidden / pagehide) and
+// notes the boot phase it reaches. Three starts with no clear = the app
+// opens in safe mode: no automatic connections, a notice naming the last
+// phase, and a way back to a normal boot.
+export const BOOT_GUARD = `<script>try{var _bk='coinos-boot',_bn=+(localStorage.getItem(_bk)||0)+1;localStorage.setItem(_bk,String(_bn));window.__bootPhase=localStorage.getItem('coinos-boot-phase')||'';if(_bn>=3)window.__safeBoot=_bn;}catch(e){}</script>`;
+
// Lazy bundles load as classic scripts into the page's global scope, where
// the main bundle's minified top-level names already live — two Bun outputs
// can mint the same identifier and die with "already been declared". An IIFE
@@ -350,7 +365,9 @@ async function bundleApp({ minify, features, staging, splitting = false }) {
// (Mediabunny, ~700 KB) would ride along in a file meant to be small
// and offline — there it uploads the original, as before
...(splitting ? [] : [{ name: 'no-video-compress', setup(build) {
- build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; } export async function streamVideo() { return null; } export const STREAM_MIN_S = 90;' }));
+ // likewise hls.js (~400 KB): Firefox's player for live streams
+ build.onLoad({ filter: /src\/hls-player\.js$/ }, () => ({ loader: 'js', contents: 'export async function attachHls() { return null; }' }));
} }])],
});
if (!result.success) {
@@ -386,21 +403,47 @@ const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za
// fetch the live site over the top of them.
const NO_SW = !!env('NO_SW');
+// What a link to the site unfurls as (chat apps, social posts, search). The
+// server-rendered note and profile pages swap this block for their own
+// (feed/note-page.js siteTagsOut), so the markers must stay.
+const SHARE_IMAGE = 'https://v3.coinos.io/og-image.png';
+const SHARE_DESC = 'Self-custody Bitcoin wallet in your browser: Lightning, Ark and Nostr, with your own keys.';
+const siteTags = (staging) => `<!--site-tags-->
+<meta name="description" content="${SHARE_DESC}">
+<meta property="og:type" content="website">
+<meta property="og:site_name" content="Coinos">
+<meta property="og:title" content="${staging ? 'Coinos Staging' : 'Coinos'}">
+<meta property="og:description" content="${SHARE_DESC}">
+<meta property="og:image" content="${SHARE_IMAGE}">
+<meta property="og:image:width" content="1200">
+<meta property="og:image:height" content="630">
+<meta property="og:image:alt" content="The coinos logo">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:image" content="${SHARE_IMAGE}">
+<!--/site-tags-->
+`;
+
async function pageHtml({ css, staging, pwa, scriptHtml }) {
+ // On https the page asks for its http:// pictures and clips (and ws://
+ // relays) over https: a post's one http:// clip made Chrome call the whole
+ // page "not secure" (displayed mixed content), and the Android app (a TWA)
+ // then showed the address bar. Not on http (local test servers).
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
+<script>if(location.protocol==='https:')document.write('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">')</script>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-content">
<meta name="color-scheme" content="light dark">
<title>${staging ? 'Coinos Staging' : 'Coinos'}</title>
<link rel="icon" href="${FAVICON}">
-${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script>
+${siteTags(staging)}${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script>
<style>${css}</style>
</head>
<body>
<div id="app">${bootShell(await brandSvg(), staging)}</div>
${BOOT_SHELL_SCRIPT}
+${BOOT_GUARD}
${scriptHtml}
${pwa && !NO_SW ? SW_REGISTER + '\n' : ''}</body>
</html>`;
@@ -524,6 +567,8 @@ if (import.meta.main) {
// ...and the default profile covers, full size and the wizard's preview copies
for (const d of ['punks', 'punks-sm', 'covers', 'covers-sm'])
for (const f of readdirSync('static/' + d)) await Bun.write(`dist/${d}/${f}`, Bun.file(`static/${d}/${f}`));
+ // the developer docs, a plain page at /docs/
+ for (const f of readdirSync('static/docs')) await Bun.write(`dist/docs/${f}`, Bun.file(`static/docs/${f}`));
const kb = (Buffer.byteLength(html) / 1024).toFixed(0);
const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0);
diff --git a/bun.lock b/bun.lock
index 10e82e2..b3e6b15 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
+ "hls.js": "1.6.13",
"jsqr": "^1.4.0",
"mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
@@ -68,6 +69,8 @@
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+ "hls.js": ["[email protected]", "", {}, "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA=="],
+
"is-fullwidth-code-point": ["[email protected]", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jsqr": ["[email protected]", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
diff --git a/fdroid/io.coinos.fdroid.yml b/fdroid/io.coinos.fdroid.yml
index 03a7373..26bac72 100644
--- a/fdroid/io.coinos.fdroid.yml
+++ b/fdroid/io.coinos.fdroid.yml
@@ -5,6 +5,17 @@
# publish the coinos-signed release once its own build matches (Binaries +
# AllowedAPKSigningKeys). Summary/description/icon/changelogs live in
# fastlane/ in this repo — F-Droid pulls them from there.
+AntiFeatures:
+ NonFreeNet:
+ en-US: Posts load pictures, videos and link previews from third-party media hosts
+ (nostr.build, primal.net and others, YouTube thumbnails from i.ytimg.com), name@domain
+ payment addresses are looked up through Cloudflare's and Google's DNS-over-HTTPS
+ resolvers, and block explorer data comes from mempool.space.
+ TetheredNet:
+ en-US: Spending and Lightning payments go through the coinos Ark server (ark.coinos.io),
+ @coinos.io payment addresses through the coinos name registrar, and chat and
+ notifications through the coinos relays. The server code is published at git.coinos.io,
+ but this build is fixed to the coinos instances.
Categories:
- Wallet
License: MIT
diff --git a/feed/note-page.js b/feed/note-page.js
index 3145f14..89b2f43 100644
--- a/feed/note-page.js
+++ b/feed/note-page.js
@@ -126,6 +126,12 @@ function nameOf(data, pk) {
try { const m = JSON.parse(p.content); return { name: m.display_name || m.name || null, picture: m.picture || null }; } catch { return {}; }
}
+// index.html carries the site's own share tags between these markers; a
+// rendered page drops them for its own (crawlers take the first of a pair).
+// The site's picture stays as the stand-in for a page that has none.
+export const siteTagsOut = (html) => html.replace(/<!--site-tags-->[\s\S]*?<!--\/site-tags-->\n?/, '');
+export const siteImage = (origin) => (origin ? origin + '/og-image.png' : '');
+
// The app's index.html with the thread and its preview tags in the head.
export function renderNotePage(html, data, ref, origin) {
const focus = [data.root, ...data.replies].find((e) => e.id === ref.id) || data.root;
@@ -141,13 +147,13 @@ export function renderNotePage(html, data, ref, origin) {
desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
desc ? `<meta name="description" content="${esc(desc)}">` : '',
origin ? `<meta property="og:url" content="${esc(origin + '/' + (ref.path || ''))}">` : '',
- image ? `<meta property="og:image" content="${esc(image)}">` : '',
- `<meta name="twitter:card" content="${image && image !== who.picture ? 'summary_large_image' : 'summary'}">`,
+ image || siteImage(origin) ? `<meta property="og:image" content="${esc(image || siteImage(origin))}">` : '',
+ `<meta name="twitter:card" content="${!image || image !== who.picture ? 'summary_large_image' : 'summary'}">`,
// the author's face starts downloading with the page, not after the app boots
who.picture && /^https:\/\//.test(who.picture) ? `<link rel="preload" as="image" href="${esc(who.picture)}">` : '',
`<script type="application/json" id="boot-thread">${scriptJson(data)}</script>`,
].filter(Boolean).join('\n');
- let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ let out = siteTagsOut(html).replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
return out;
}
diff --git a/feed/profile-page.js b/feed/profile-page.js
index a30c2ab..623dc09 100644
--- a/feed/profile-page.js
+++ b/feed/profile-page.js
@@ -11,7 +11,7 @@
// page goes out (and the build carries on into the cache).
import { decode } from 'nostr-tools/nip19';
import { verifyEvent } from 'nostr-tools/pure';
-import { NOTE_RELAYS, esc, scriptJson } from './note-page.js';
+import { NOTE_RELAYS, esc, scriptJson, siteTagsOut, siteImage } from './note-page.js';
export const PROFILE_PATH = /^\/((?:npub1|nprofile1)[a-z0-9]+|[A-Za-z0-9._-]{1,30})\/?$/;
// single-segment app routes and files that are never a person
@@ -186,14 +186,14 @@ export function renderProfilePage(html, data, ref, origin) {
desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
desc ? `<meta name="description" content="${esc(desc)}">` : '',
origin ? `<meta property="og:url" content="${esc(origin + '/' + ref.path)}">` : '',
- picture ? `<meta property="og:image" content="${esc(picture)}">` : '',
- `<meta name="twitter:card" content="summary">`,
+ picture || siteImage(origin) ? `<meta property="og:image" content="${esc(picture || siteImage(origin))}">` : '',
+ `<meta name="twitter:card" content="${picture ? 'summary' : 'summary_large_image'}">`,
// the face and the cover start downloading with the page
picture ? `<link rel="preload" as="image" href="${esc(picture)}">` : '',
banner ? `<link rel="preload" as="image" href="${esc(banner)}">` : '',
`<script type="application/json" id="boot-profile">${scriptJson({ ...data, path: ref.path })}</script>`,
].filter(Boolean).join('\n');
- let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ let out = siteTagsOut(html).replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
return out;
}
diff --git a/names/compose-libretranslate.yml b/names/compose-libretranslate.yml
deleted file mode 100644
index 80ed9c6..0000000
--- a/names/compose-libretranslate.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-# LibreTranslate for post translation (coinos v3). Reached only by the
-# names registrar over the docker network (POST /translate there); no port
-# on the host, no web UI. Models live in the lt-data volume.
-services:
- libretranslate:
- image: libretranslate/libretranslate:v1.9.6
- container_name: libretranslate
- environment:
- LT_LOAD_ONLY: en,es,fr,de,pt,id,tr,vi,ru,ar,ur,hi,bn,ja,ko,zh,it,nl,pl,uk,fa,he,th,sv,cs,el,ro,hu,fi,da,nb,ca
- LT_DISABLE_WEB_UI: "true"
- LT_CHAR_LIMIT: "5000"
- LT_THREADS: "4"
- LT_UPDATE_MODELS: "true"
- volumes:
- - lt-data:/home/libretranslate/.local
- networks: [net]
- deploy:
- resources:
- limits:
- memory: 8g
- restart: unless-stopped
-volumes:
- lt-data:
-networks:
- net:
- external: true
diff --git a/names/ownership.js b/names/ownership.js
deleted file mode 100644
index 19ae4ef..0000000
--- a/names/ownership.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// A migration grant names the identity that owns the address. Its manager
-// may submit the registration without becoming that identity.
-export function registrationIdentity({ existing, signer, manager, grant, granted }) {
- const pubkey = existing?.pubkey || (granted ? grant.pubkey : signer);
- const nominated = /^[0-9a-f]{64}$/.test(manager || '') && manager !== pubkey;
- return {
- pubkey,
- manager: nominated ? manager : existing?.manager || (granted ? grant.manager : undefined),
- };
-}
diff --git a/names/preview.js b/names/preview.js
deleted file mode 100644
index b9af975..0000000
--- a/names/preview.js
+++ /dev/null
@@ -1,133 +0,0 @@
-// Link previews for the wallet's feed: the Open Graph card a page describes
-// for itself (title, one line of description, a picture, the site's name).
-// A browser can't read another site's HTML, so the registrar fetches it
-// once on the reader's behalf and hands back only what a card needs.
-//
-// The server sees more than the internet does (containers, the LAN), so a
-// URL is fetched only after its host resolves to public addresses — and
-// again after every redirect. Bodies are capped, and only HTML is read.
-import { promises as dns } from 'node:dns';
-
-const MAX_BODY = 512 * 1024;
-const TIMEOUT_MS = 7000;
-const MAX_HOPS = 4;
-const UA = 'Mozilla/5.0 (compatible; coinos-link-preview/1.0; +https://coinos.io)';
-
-const isPrivate4 = (ip) => {
- const p = ip.split('.').map(Number);
- return p[0] === 10 || p[0] === 127 || p[0] === 0
- || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
- || (p[0] === 192 && p[1] === 168)
- || (p[0] === 169 && p[1] === 254)
- || (p[0] === 100 && p[1] >= 64 && p[1] <= 127)
- || p[0] >= 224;
-};
-const isPrivate6 = (ip) => {
- const s = ip.toLowerCase();
- if (s === '::1' || s === '::') return true;
- if (s.startsWith('::ffff:')) return isPrivate4(s.slice(7));
- return /^(fc|fd|fe[89ab])/.test(s);
-};
-export const isPrivateIp = (ip) => (ip.includes(':') ? isPrivate6(ip) : isPrivate4(ip));
-
-// A URL the server may fetch: http(s), a default port, a public host.
-async function publicUrl(str) {
- let u;
- try { u = new URL(str); } catch { return null; }
- if (!/^https?:$/.test(u.protocol)) return null;
- if (u.port && u.port !== '80' && u.port !== '443') return null;
- if (u.username || u.password) return null;
- const host = u.hostname.replace(/^\[|\]$/g, '');
- if (!host || host === 'localhost' || /\.(local|internal|localhost)$/i.test(host)) return null;
- let addrs;
- try { addrs = await dns.lookup(host, { all: true }); } catch { return null; }
- if (!addrs.length || addrs.some((a) => isPrivateIp(a.address))) return null;
- return u;
-}
-
-const ENTITIES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>', nbsp: ' ', ndash: '–', mdash: '—', hellip: '…',
- lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', middot: '·', bull: '•', euro: '€', pound: '£' };
-const decodeEntities = (s) => String(s || '')
- .replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ''; } })
- .replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(Number(d)); } catch { return ''; } })
- .replace(/&([a-z]+);/gi, (m, e) => ENTITIES[e.toLowerCase()] ?? m);
-const tidy = (s, max) => decodeEntities(s).replace(/\s+/g, ' ').trim().slice(0, max);
-
-// Every <meta> in the head, whichever way its attributes are ordered.
-function metaTags(html) {
- const out = [];
- const re = /<meta\s+([^>]*?)\/?>/gi;
- let m;
- while ((m = re.exec(html))) {
- const attrs = {};
- const ar = /([a-zA-Z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
- let a;
- while ((a = ar.exec(m[1]))) attrs[a[1].toLowerCase()] = a[3] ?? a[4] ?? a[5] ?? '';
- const key = (attrs.property || attrs.name || attrs.itemprop || '').toLowerCase();
- if (key && attrs.content != null) out.push([key, attrs.content]);
- }
- return out;
-}
-
-// What a card shows, from a page's HTML. Exported for the tests.
-export function parsePreview(html, baseUrl) {
- const head = html.slice(0, MAX_BODY);
- const meta = new Map();
- for (const [k, v] of metaTags(head)) if (!meta.has(k) && v.trim()) meta.set(k, v);
- const pick = (...keys) => { for (const k of keys) if (meta.get(k)) return meta.get(k); return ''; };
- const titleTag = (head.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
- const title = tidy(pick('og:title', 'twitter:title') || titleTag, 200);
- const description = tidy(pick('og:description', 'twitter:description', 'description'), 300);
- let image = pick('og:image:secure_url', 'og:image', 'twitter:image', 'twitter:image:src');
- if (image) {
- try { image = new URL(decodeEntities(image).trim(), baseUrl).href; } catch { image = ''; }
- if (!/^https?:/.test(image)) image = '';
- }
- let site = tidy(pick('og:site_name'), 80);
- if (!site) { try { site = new URL(baseUrl).hostname.replace(/^www\./, ''); } catch {} }
- if (!title && !image) return null;
- return { url: baseUrl, title, description, image, site };
-}
-
-// Read at most MAX_BODY bytes of a response.
-async function readCapped(res) {
- const reader = res.body?.getReader();
- if (!reader) return '';
- const chunks = []; let got = 0;
- while (got < MAX_BODY) {
- const { value, done } = await reader.read();
- if (done) break;
- chunks.push(value); got += value.length;
- }
- try { await reader.cancel(); } catch {}
- return new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
-}
-
-// Fetch a page and describe it, or null when there is nothing to show
-// (not HTML, unreachable, a private host, no title or picture).
-export async function fetchPreview(str) {
- let u = await publicUrl(str);
- for (let hop = 0; u && hop < MAX_HOPS; hop++) {
- let res;
- try {
- res = await fetch(u.href, {
- redirect: 'manual',
- signal: AbortSignal.timeout(TIMEOUT_MS),
- headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en' },
- });
- } catch { return null; }
- if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
- let next;
- try { next = new URL(res.headers.get('location'), u.href).href; } catch { return null; }
- try { await res.body?.cancel(); } catch {}
- u = await publicUrl(next);
- continue;
- }
- if (!res.ok) return null;
- const type = (res.headers.get('content-type') || '').toLowerCase();
- if (!/text\/html|application\/xhtml/.test(type)) { try { await res.body?.cancel(); } catch {} return null; }
- const html = await readCapped(res);
- return parsePreview(html, u.href);
- }
- return null;
-}
diff --git a/names/server.js b/names/server.js
deleted file mode 100644
index 77d758c..0000000
--- a/names/server.js
+++ /dev/null
@@ -1,1840 +0,0 @@
-// BIP-353 name registrar for halwallet.app — gives every hal user a
-// ₿[email protected] payment address.
-//
-// Auth on every write is NIP-98 (HTTP Auth, kind 27235).
-//
-// A name is a DNSSEC-signed TXT record at
-// <name>.user._bitcoin-payment.halwallet.app
-// whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address,
-// extensible later with lno= etc). This service is the write path: it
-// validates a claim signed by the wallet's nostr key, enforces first-come
-// ownership by that key, and mirrors the record into Cloudflare DNS.
-//
-// WHAT THIS SERVICE LEARNS: name ↔ nostr pubkey ↔ payment URI. All of it is
-// public by construction (DNS is public). It cannot spend anything.
-//
-// Deliberately dumb: one JSON state file, no accounts, no email.
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { verifyEvent, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import * as nip44 from 'nostr-tools/nip44';
-import { wrapManyEvents } from 'nostr-tools/nip17';
-import { SimplePool } from 'nostr-tools/pool';
-import { npubEncode, decode as nip19Decode } from 'nostr-tools/nip19';
-import { createHash, randomBytes } from 'node:crypto';
-import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync } from '@scure/bip39';
-import { lnBackend } from '../bridge/ln.js';
-import { ArkManager } from '../src/ark/manager.js';
-import { fetchPreview } from './preview.js';
-import { translate, translateDeepl, languages } from './translate.js';
-import { decodeAddress } from '../src/ark/proto.js';
-import { decodeNoffer } from '../src/noffer.js';
-import { decodeBolt11 } from '../src/ark/lightning.js';
-import { decodeOffer } from '../src/bolt12.js';
-import { registrationIdentity } from './ownership.js';
-
-const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
- || join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, domains: { '<domain>': { zoneId } }, domain (default), cfEmail, cfKey, stateFile,
-// ln: { kind:'socket', path } — the ASP's CLN, for per-name bolt12 offers,
-// forwarder: { mnemonic, ark, esplora, network } — a small float ark wallet
-// that forwards settled offer payments to the name's ark address }
-
-const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims
-const PUBLIC_BASE = CFG.publicBase || 'https://names.coinos.io';
-const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } };
-const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json');
-const TTL = 300; // record TTL: BIP-353 wallets cache by this
-
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// names that must never be claimable
-const RESERVED = new Set([
- 'admin', 'administrator', 'root', 'hal', 'halwallet', 'www', 'mail', 'help',
- 'support', 'info', 'pay', 'payments', 'wallet', 'staging', 'names', 'api',
- 'user', 'users', 'coinos', 'nostr', 'ark', 'test', 'dev', 'security', 'abuse',
-]);
-const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,29}$/;
-// What a NEW name may be: letters and digits, 2–24 — coinos v2's rule
-// (/^[\p{L}\p{N}]{2,24}$/u there), held to ASCII because a name is a DNS
-// label here. NAME_RE stays the wider test for names that already exist
-// (a dozen claimed before the rule: bitcoin-education, barefoot.strider…),
-// v2 migrations carrying their own name, and npub-shaped defaults.
-const NEW_NAME_RE = /^[a-z0-9]{2,24}$/;
-const NEW_NAME_ERR = 'names can only have letters and numbers (2–24)';
-
-let state = (() => {
- try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return { names: {} }; }
-})();
-function persist() {
- mkdirSync(dirname(STATE), { recursive: true });
- const tmp = STATE + '.tmp';
- writeFileSync(tmp, JSON.stringify(state));
- renameSync(tmp, STATE);
-}
-
-// ---------------------------------------------------------------------------
-// Cloudflare DNS
-// ---------------------------------------------------------------------------
-
-const CF = 'https://api.cloudflare.com/client/v4';
-const cfHeaders = {
- 'X-Auth-Email': CFG.cfEmail, 'X-Auth-Key': CFG.cfKey, 'content-type': 'application/json',
-};
-const recordName = (name, domain) => `${name}.user._bitcoin-payment.${domain}`;
-
-async function cfWrite(name, domain, uri, existingId) {
- // TXT character-strings cap at 255 bytes; BIP-353 readers concatenate the
- // chunks in order, so a long ark+lno URI just spans several quoted strings.
- const zoneId = DOMAINS[domain]?.zoneId;
- if (!zoneId) throw new Error('unknown domain');
- const chunks = uri.match(/.{1,255}/g).map((c) => `"${c}"`).join(' ');
- const body = JSON.stringify({
- type: 'TXT', name: recordName(name, domain), content: chunks, ttl: TTL,
- });
- const url = existingId
- ? `${CF}/zones/${zoneId}/dns_records/${existingId}`
- : `${CF}/zones/${zoneId}/dns_records`;
- const r = await fetch(url, { method: existingId ? 'PUT' : 'POST', headers: cfHeaders, body });
- const j = await r.json();
- if (!j.success) throw new Error('dns write failed: ' + JSON.stringify(j.errors).slice(0, 200));
- return j.result.id;
-}
-async function cfDelete(domain, recordId) {
- const zoneId = DOMAINS[domain]?.zoneId;
- if (!zoneId) throw new Error('unknown domain');
- const r = await fetch(`${CF}/zones/${zoneId}/dns_records/${recordId}`, {
- method: 'DELETE', headers: cfHeaders,
- });
- const j = await r.json();
- if (!j.success) throw new Error('dns delete failed');
-}
-
-// ---------------------------------------------------------------------------
-// bolt12 offers + the settle forwarder
-// ---------------------------------------------------------------------------
-// Each name gets a static BOLT 12 offer on the ASP's CLN, published in the
-// DNS record as lno=. Offer payments land on the ASP node, so a forwarder
-// pushes each settled payment on to the name's ark address from a small
-// float wallet (an ordinary arkoor send — free). Custody window: the seconds
-// between LN settle and the ark send. The float must be topped up by the
-// operator; LN income accrues on the node as the offset.
-
-// ---------------------------------------------------------------------------
-// LNURL-pay: the same address, payable by wallets that have never heard of
-// DNS payment instructions (which is most of them).
-//
-// Two ways to produce the invoice, preferring the one that touches no funds:
-// 1. ASK THE RECIPIENT. If their wallet published a CLINK offer key we send
-// it a kind-21001 request over nostr; their wallet (or its service
-// worker) mints an invoice against their own balance and we just hand it
-// back. Nothing passes through us.
-// 2. MINT HERE. Otherwise our node issues the invoice and the settle
-// forwarder pushes the money on to their address, exactly like the
-// BOLT 12 offer path — custodial for the seconds in between.
-const OFFER_KIND = 21001;
-const LNURL_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
-const pool = new SimplePool();
-
-// Our own nostr identity: used to ask recipients for invoices, and to sign
-// NIP-57 zap receipts for payments we mint ourselves.
-if (!state.serviceSk) { state.serviceSk = Buffer.from(generateSecretKey()).toString('hex'); persist(); }
-const SERVICE_SK = Uint8Array.from(Buffer.from(state.serviceSk, 'hex'));
-const SERVICE_PK = getPublicKey(SERVICE_SK);
-
-// ---------------------------------------------------------------------------
-// Welcome DM — every first-time registrant gets a NIP-17 DM from Adam.
-// config: welcome: { sk: '<hex privkey>', message: '…' }. The registration is
-// the one moment we reliably know a new user's npub, so it doubles as the
-// "new user" signal. One per pubkey, ever (state.welcomed).
-// ---------------------------------------------------------------------------
-
-const WELCOME = CFG.welcome && /^[0-9a-f]{64}$/.test(CFG.welcome.sk || '') ? {
- sk: Uint8Array.from(Buffer.from(CFG.welcome.sk, 'hex')),
- message: CFG.welcome.message || 'Welcome to coinos!',
-} : null;
-
-async function inboxRelaysOf(pubkey) {
- try {
- const evs = await pool.querySync(LNURL_RELAYS, { kinds: [10002, 10050], authors: [pubkey] }, { maxWait: 4000 });
- const newest = (kind) => evs.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0];
- const dm = newest(10050);
- if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r.slice(0, 4); }
- const rl = newest(10002);
- if (rl) return rl.tags.filter((t) => t[0] === 'r' && (!t[2] || t[2] === 'read')).map((t) => t[1]).slice(0, 4);
- } catch {}
- return [];
-}
-
-async function sendWelcome(pubkey, linkedKeys = []) {
- if (!WELCOME) return;
- state.welcomed ||= {};
- // One welcome per PERSON, not per key: a Google sign-up claims twice —
- // the wallet key takes the placeholder name, then the real npub takes
- // its own with the wallet key as manager — and greeting each pubkey put
- // two identical DMs in the same inbox.
- const keys = [pubkey, ...linkedKeys].filter((k) => /^[0-9a-f]{64}$/.test(k || ''));
- const seen = keys.some((k) => state.welcomed[k]);
- for (const k of keys) state.welcomed[k] ||= Date.now();
- persist();
- if (seen) return;
- try {
- // wrapManyEvents returns [sender-copy, recipient] — publish ONLY the
- // recipient's wrap. A sender copy per registrant flooded Adam's own
- // clients with an endless stream of his own welcome message.
- const [, toPeer] = wrapManyEvents(WELCOME.sk, [{ publicKey: pubkey }], WELCOME.message);
- const inbox = await inboxRelaysOf(pubkey);
- const relays = [...new Set([...inbox, ...LNURL_RELAYS])];
- // Count acknowledgements, not attempts: a socket that died under the
- // pool (the home uplink drops every connection at once) swallows a
- // publish without a word, and four welcomes on 2026-10-07 went nowhere
- // while the log said "sent". Nobody heard it → drop those sockets and
- // try again on fresh ones, then say what really happened.
- let acked = await publishAcked(relays, toPeer);
- for (let i = 1; !acked.length && i <= 3; i++) {
- try { pool.close(relays); } catch {}
- await new Promise((r) => setTimeout(r, 5000 * i));
- acked = await publishAcked(relays, toPeer);
- }
- if (acked.length) log(`welcome DM sent to ${pubkey.slice(0, 12)} (${acked.join(', ')})`);
- else {
- for (const k of keys) delete state.welcomed[k]; persist(); // undelivered: the next claim may try again
- log(`welcome DM to ${pubkey.slice(0, 12)} FAILED: no relay accepted it`);
- }
- } catch (e) {
- log('welcome DM failed: ' + e.message);
- }
-}
-
-// Publish and return the relays that answered OK within the time limit.
-async function publishAcked(relays, ev, ms = 10000) {
- const timeout = new Promise((_, j) => setTimeout(() => j(new Error('timeout')), ms));
- const res = await Promise.allSettled(pool.publish(relays, ev).map((p) => Promise.race([p, timeout])));
- return relays.filter((_, i) => res[i].status === 'fulfilled').map((r) => r.replace(/^wss:\/\//, ''));
-}
-
-const lnurlMeta = (address) => JSON.stringify([
- ['text/plain', `Paying ${address}`],
- ['text/identifier', address],
-]);
-
-// Ask the recipient's own CLINK service for an invoice (kind 21001).
-// dest: { pubkey, relays, offerId } — a hal wallet listening on our relays,
-// or an external service (Lightning.Pub beside the user's own node) on the
-// relay its noffer names.
-function requestInvoiceFromWallet(dest, { amountSat, description, zap }) {
- return new Promise((resolve) => {
- let done = false;
- const finish = (v) => { if (!done) { done = true; try { sub.close(); } catch {} resolve(v); } };
- const key = nip44.getConversationKey(SERVICE_SK, dest.pubkey);
- const payload = { offer: dest.offerId, amount_sats: amountSat };
- if (description) payload.description = description.slice(0, 100);
- if (zap) payload.zap = zap;
- const req = finalizeEvent({
- kind: OFFER_KIND, created_at: Math.floor(Date.now() / 1000),
- tags: [['p', dest.pubkey], ['clink_version', '1']],
- content: nip44.encrypt(JSON.stringify(payload), key),
- }, SERVICE_SK);
- const sub = pool.subscribeMany(dest.relays,
- { kinds: [OFFER_KIND], '#e': [req.id], since: Math.floor(Date.now() / 1000) - 5 },
- {
- onevent: (ev) => {
- try {
- const body = JSON.parse(nip44.decrypt(ev.content, key));
- if (body.bolt11) return finish(body.bolt11);
- } catch {}
- },
- });
- Promise.allSettled(pool.publish(dest.relays, req)).catch(() => {});
- setTimeout(() => finish(null), 6000); // services that aren't listening
- });
-}
-
-// Where CLINK invoice requests for this name go. An external noffer the
-// owner registered wins over the hal wallet's own offer key — pointing the
-// name at their node is an explicit choice.
-const clinkDest = (rec) => (rec?.noffer
- ? { pubkey: rec.noffer.pubkey, relays: [...new Set([rec.noffer.relay, ...LNURL_RELAYS])], offerId: rec.noffer.offerId || '' }
- : rec?.offerPk ? { pubkey: rec.offerPk, relays: LNURL_RELAYS, offerId: 'zap_default' } : null);
-
-// An invoice a CLINK service hands back is only usable if it demands exactly
-// the sats owed on the right network — paying (or relaying) one on trust
-// would let a hostile service name any amount.
-// An invoice a payer can actually pay: the right amount, the right network,
-// and not about to expire — an Ark server answers a payment hash it already
-// holds with THAT invoice, and a wallet whose receive counter slipped once
-// handed back a days-old one; the payer saw "invoice expired".
-function usableInvoice(pr, sat, network = null) {
- try {
- const dec = decodeBolt11(pr);
- if (dec.amountMsat !== BigInt(sat) * 1000n) return false;
- if (network && dec.network !== network) return false;
- if (dec.expiresAt && dec.expiresAt < Date.now() + 60_000) return false;
- return true;
- } catch { return false; }
-}
-
-const ln = CFG.ln ? lnBackend(CFG.ln) : null;
-// Optional mutinynet CLN — lets staging sell (play-money) hats. Nothing else
-// uses it: names, offers and forwards remain mainnet affairs.
-const lnMut = CFG.lnMut ? lnBackend(CFG.lnMut) : null;
-
-// -- routing-fee quotes (the /lnquote endpoint) ------------------------------
-
-// Ark locks are sat-granular and the locked surplus IS xpay's maxfee, which
-// binds hard — a 9 msat route under a 0 budget fails outright (verified live:
-// "Could not find route without excessive cost", then the +1-sat retry paid).
-// So sub-sat fees round UP: the honest minimum a client can bring is 1 sat.
-// Making those truly free needs the ASP to bring the msats itself (captaind
-// patch: floor max_routing_fee at 1 sat), not a rounder-down here.
-const quoteSat = (msat) => Math.ceil(msat / 1000);
-
-const _nodeIds = new Map();
-async function ourNodeId(lnq = ln) {
- if (!_nodeIds.has(lnq)) _nodeIds.set(lnq, (await lnq.call('getinfo')).id);
- return _nodeIds.get(lnq);
-}
-
-// Ask askrene (the router xpay itself uses) what delivering amountMsat to
-// dest would cost from here. The maxfee passed in is only the SEARCH cap —
-// generous on purpose, since the user sees and approves the quoted fee
-// before anything is locked.
-async function routeFeeMsat(our, dest, amountMsat, finalCltv, lnq = ln) {
- const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05));
- const r = await lnq.call('getroutes', {
- source: our, destination: dest, amount_msat: amountMsat,
- // localchans FIRST: it re-adds our channels with their advertised fees,
- // so sourcefree must come after it to zero our own hops (verified live —
- // the other order quotes 1 sat on a direct-peer payment).
- layers: ['auto.localchans', 'auto.sourcefree'],
- maxfee_msat: cap, final_cltv: finalCltv,
- });
- if (!r.routes || !r.routes.length) throw new Error('no route found');
- const sent = r.routes.reduce((s, rt) => s + Number(rt.path[0].amount_msat), 0);
- const delivered = r.routes.reduce((s, rt) => s + Number(rt.amount_msat), 0);
- return Math.max(0, sent - delivered);
-}
-let fwd = null; // the float ark wallet
-if (CFG.forwarder?.mnemonic) {
- const account = HDKey.fromMasterSeed(mnemonicToSeedSync(CFG.forwarder.mnemonic)).derive("m/86'/0'/9'");
- fwd = await new ArkManager({
- account,
- storage: {
- load: () => state.fwdArk || null,
- save: (s) => { state.fwdArk = s; persist(); },
- },
- arkUrl: CFG.forwarder.ark, esploraUrl: CFG.forwarder.esplora, network: CFG.forwarder.network || 'mainnet',
- }).init();
- // Read the mailbox before reporting: a top-up sent while we were down (or
- // between syncs) lives there, and balance() only counts what state has seen.
- await fwd.sync().catch(() => {});
- log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`);
-}
-
-async function makeOffer(address) { // "name@domain"
- if (!ln) return null;
- const o = await ln.call('offer', { amount: 'any', description: address });
- return { offerId: o.offer_id, bolt12: o.bolt12 };
-}
-
-const arkParamOf = (uri) => {
- const m = String(uri).match(/[?&]ark=([^&]+)/);
- return m ? decodeURIComponent(m[1]) : null;
-};
-
-// A float arkoor delivery is indistinguishable from any other ark receive on
-// the wallet's side (the mailbox protocol carries no memo), so remember each
-// LN settlement we forwarded that way: the wallet asks /lnreceipts and
-// matches by amount + delivery time to put the ⚡ on the right history rows.
-// Bounded per name; entries age out on the invoice-retention horizon. Only
-// float deliveries are recorded — a viaClink delivery runs the wallet's own
-// LN-receive flow, which tags itself.
-function recordLnReceipt(name, sat, hash) {
- const all = state.lnReceipts ||= {};
- const cutoff = Date.now() - 7 * 86400_000;
- all[name] = [...(all[name] || []).filter((r) => r.ts > cutoff),
- { sat, ts: Date.now(), ...(hash ? { hash } : {}) }].slice(-50);
-}
-
-// Deliver a settled payment to the name's owner. Two ways, and we try the
-// one that works while they're asleep first:
-// 1. an arkoor send from the float — instant, free, needs no cooperation
-// 2. ask their wallet for an invoice and pay it over Lightning — needs no
-// float at all, but only works while something of theirs is listening
-// Failing both, it queues and retries; nothing is ever dropped silently.
-async function forward(name, sat, hash) {
- const rec = state.names[name];
- const dest = rec && arkParamOf(rec.uri);
- const clink = clinkDest(rec);
- if (!dest && !clink) { log(`forward: nowhere to send ${sat} sat for ${name}`); return; }
-
- const viaFloat = async () => {
- if (!dest || !fwd || fwd.balance().spendableSat < sat) return false;
- await fwd.send(dest, sat);
- recordLnReceipt(name, sat, hash);
- persist();
- log(`forwarded ${sat} sat to ${name}`);
- return true;
- };
- const viaClink = async () => {
- if (!clink || !ln) return false;
- const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: `${name} payment` });
- if (!pr) return false;
- if (!usableInvoice(pr, sat, 'mainnet')) {
- log(`forward: ${name}'s service returned an unusable invoice — refusing to pay it`);
- return false;
- }
- await ln.pay(pr, { maxfeeSat: Math.max(2, Math.ceil(sat * 0.005)) });
- log(`delivered ${sat} sat to ${name} over lightning (no float needed)`);
- return true;
- };
- // A name pointed at its owner's own node delivers there first; the float
- // is the fallback. Everyone else gets the instant free arkoor send first.
- for (const step of rec?.noffer ? [viaClink, viaFloat] : [viaFloat, viaClink]) {
- if (await step()) return;
- }
- throw new Error(fwd && dest ? 'insufficient float and the wallet is not listening' : 'no delivery route');
-}
-
-// ---------------------------------------------------------------------------
-// Point of sale — a coinos POS terminal (coinos-pos, an ESP32 keypad) works
-// against a name with a bearer token its owner mints from Settings. The
-// terminal can ring up sales (mint invoices for the name) and learn when they
-// settle; it can never spend. POS invoices are always minted HERE, never via
-// CLINK, so the settle loop sees them and the merchant's phone can be off;
-// delivery is the ordinary forward path (float arkoor), so the sats reach the
-// name's ark address seconds after settlement.
-// ---------------------------------------------------------------------------
-const POS_INVOICE_TTL = 5 * 60_000; // an LNURL tap serves the newest unpaid sale this young
-let ratesCache = { at: 0, rates: null };
-async function fiatRates() {
- if (ratesCache.rates && Date.now() - ratesCache.at < 60_000) return ratesCache.rates;
- const r = await fetch('https://coinos.io/api/rates', { signal: AbortSignal.timeout(5000) });
- if (!r.ok) throw new Error('rates unavailable');
- ratesCache = { at: Date.now(), rates: await r.json() };
- return ratesCache.rates;
-}
-const posTokens = () => (state.posTokens ||= {}); // token -> { key, currency, created }
-const posInvoices = () => (state.posInvoices ||= {}); // payment_hash -> { key, sat, fiat, currency, rate, bolt11, created, paid?, received? }
-function posAuth(req, url) {
- const h = req.headers.get('authorization') || '';
- const tok = (h.match(/^Bearer\s+(.+)$/i) || [])[1] || url.searchParams.get('token') || '';
- const rec = tok && posTokens()[tok];
- if (!rec || !state.names[rec.key]) return null; // a released name takes its tokens with it
- return { token: tok, ...rec };
-}
-function posMintToken(key, currency) {
- const token = randomBytes(32).toString('hex');
- posTokens()[token] = { key, currency: /^[A-Z]{3}$/.test(String(currency || '')) ? currency : 'USD', created: Date.now() };
- persist();
- log(`pos token minted for ${key}`);
- return token;
-}
-// the newest unpaid sale the terminal rang up in the last five minutes, if any
-function posPendingInvoice(key) {
- let best = null;
- for (const [hash, v] of Object.entries(posInvoices())) {
- if (v.key !== key || v.paid || Date.now() - v.created > POS_INVOICE_TTL) continue;
- if (!best || v.created > best.created) best = { hash, ...v };
- }
- return best;
-}
-function sweepPos() {
- const cutoff = Date.now() - 7 * 86400_000;
- for (const [h, v] of Object.entries(posInvoices())) if (v.created < cutoff) delete posInvoices()[h];
-}
-const posSockets = new Set();
-const posPaymentMsg = (hash, v) => JSON.stringify({ type: 'payment', data: { iid: hash, amount: v.received ?? v.sat, tip: 0 } });
-function posSettled(hash, sat) {
- const v = posInvoices()[hash];
- if (!v) return;
- v.paid = Date.now();
- v.received = sat;
- persist();
- for (const ws of posSockets) {
- if (ws.data.key !== v.key || (ws.data.hash && ws.data.hash !== hash)) continue;
- try { ws.send(posPaymentMsg(hash, v)); } catch {}
- }
- log(`pos ${v.key}: ${sat} sat paid`);
-}
-
-// NIP-57: a zap we invoiced is only "a zap" once we publish the receipt,
-// signed by the key our LNURL response advertises.
-async function publishZapReceipt(pending, inv) {
- try {
- const zapReq = JSON.parse(pending.zap);
- const tags = [
- ...zapReq.tags.filter((t) => t[0] === 'p' || t[0] === 'e' || t[0] === 'a'),
- ['P', zapReq.pubkey],
- ['bolt11', pending.bolt11],
- ['description', pending.zap],
- ];
- if (inv.payment_preimage) tags.push(['preimage', inv.payment_preimage]);
- const receipt = finalizeEvent({ kind: 9735, created_at: Math.floor(Date.now() / 1000), tags, content: '' }, SERVICE_SK);
- const relayTag = zapReq.tags.find((t) => t[0] === 'relays');
- const relays = [...new Set([...(relayTag ? relayTag.slice(1) : []), ...LNURL_RELAYS])].slice(0, 8);
- await Promise.allSettled(pool.publish(relays, receipt));
- log(`published a zap receipt for ${pending.key}`);
- } catch (e) { log('zap receipt failed: ' + e.message); }
-}
-
-// ---------------------------------------------------------------------------
-// Hats — cosmetic supporter hats, shown above the buyer's avatar in coinos.
-// Ownership is a paid CLN invoice; the registry here is what clients trust
-// (a kind-0 field would be self-asserted). Money stays on the node: it IS
-// the support. The crown is Adam's and is not for sale.
-// ---------------------------------------------------------------------------
-
-const HAT_ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
-const HAT_PRICES = {
- beanie: 21, trucker: 2100, cowboy: 21000, fedora: 21000,
- bowler: 210000, top: 210000, wizard: 2100000,
-};
-
-// Mutinynet runs a parallel hat economy (invoices minted on the mutinynet
-// CLN, grants in their own registry) so play-money purchases can exercise
-// the whole flow on staging without ever buying a hat mainnet users see.
-const hatNetOf = (v) => (v === 'mutinynet' ? 'mutinynet' : 'mainnet');
-const hatStore = (net) => (net === 'mutinynet' ? (state.hatsMut ||= {}) : (state.hats ||= {}));
-const hatLn = (net) => (net === 'mutinynet' ? lnMut : ln);
-
-function hatRec(pk, net = 'mainnet') {
- const r = hatStore(net)[pk];
- const owned = r ? [...(r.owned || [])] : [];
- if (pk === HAT_ADMIN_PK && !owned.includes('crown')) owned.push('crown');
- const equipped = r ? (r.equipped || null) : (pk === HAT_ADMIN_PK ? 'crown' : null);
- return { owned, equipped };
-}
-
-function grantHat(pk, hat, net = 'mainnet', { equip = true } = {}) {
- const r = hatStore(net)[pk] ||= { owned: [], equipped: null };
- if (!r.owned.includes(hat)) r.owned.push(hat);
- // a fresh purchase goes straight on the head; a GIFT only crowns a bare
- // one — knocking off a hat someone paid for is no way to say thanks
- if (equip || !r.equipped) r.equipped = hat;
-}
-
-async function settleLoop() {
- if (!ln || !fwd) return;
- state.lastPayIndex = state.lastPayIndex || 0;
- for (;;) {
- let inv;
- try {
- inv = await ln.call('waitanyinvoice', { lastpay_index: state.lastPayIndex, timeout: 120 });
- } catch (e) {
- if (!/timed out|Timed out/i.test(e.message)) await new Promise((r) => setTimeout(r, 5000));
- continue;
- }
- state.lastPayIndex = inv.pay_index || state.lastPayIndex;
- persist();
- // hat purchases settle here too — grant and keep the sats (that's the point)
- const hatPending = state.hatInvoices && state.hatInvoices[inv.payment_hash];
- if (hatPending) {
- grantHat(hatPending.pubkey, hatPending.hat, hatNetOf(hatPending.net));
- delete state.hatInvoices[inv.payment_hash];
- persist();
- log(`hat sold: ${hatPending.hat} (${hatNetOf(hatPending.net)}) to ${hatPending.pubkey.slice(0, 12)}`);
- continue;
- }
- const offerId = inv.local_offer_id;
- let name = offerId && Object.keys(state.names).find((n) => state.names[n].offerId === offerId);
- // custom offers (a memo'd bolt12 the user minted for e.g. a mining pool)
- if (!name && offerId && state.offers && state.offers[offerId]) name = state.offers[offerId].key;
- // LNURL invoices we minted: recorded against their payment hash
- const pending = state.invoices && state.invoices[inv.payment_hash];
- if (!name && pending) name = pending.key;
- if (!name) {
- // Usually genuinely not ours (the ASP's and boltz's invoices settle on
- // this node too) — but log it, so a lost mapping is an audit-grep away
- // instead of an archaeology project.
- log(`settle without a name: ${inv.payment_hash} ${inv.amount_received_msat || ''}`);
- continue;
- }
- const sat = Math.floor((inv.amount_received_msat?.msat ?? inv.amount_received_msat ?? 0) / 1000);
- if (!sat) continue;
- if (pending?.pos) posSettled(inv.payment_hash, sat);
- // money has arrived for this name — push-notify their devices (best effort)
- if (CFG.push && CFG.push.url && state.names[name]) {
- fetch(`${CFG.push.url}/notify`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- // this registrar's own network, so the notifier delivers only to the
- // payee's devices on it (a seed makes the same pubkey on both nets)
- body: JSON.stringify({ token: CFG.push.token, pubkey: state.names[name].pubkey, amountSat: sat, net: CFG.forwarder?.network || 'mainnet' }),
- }).catch(() => {});
- }
- // ...and POST to their webhook, if they registered one
- if (state.names[name] && state.names[name].webhook) {
- queueWebhook(name, {
- type: 'payment',
- address: name,
- amount: sat,
- hash: inv.payment_hash,
- ...(inv.payment_preimage ? { preimage: inv.payment_preimage } : {}),
- ...(inv.description ? { memo: String(inv.description).slice(0, 640) } : {}),
- ...(inv.bolt11 ? { bolt11: inv.bolt11 } : {}),
- ts: Date.now(),
- });
- }
- if (pending) {
- if (pending.zap) publishZapReceipt(pending, inv).catch(() => {});
- delete state.invoices[inv.payment_hash];
- persist();
- }
- try {
- await forward(name, sat, inv.payment_hash);
- } catch (e) {
- log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`);
- state.pending = state.pending || [];
- state.pending.push({ name, sat, hash: inv.payment_hash, ts: Date.now() });
- persist();
- }
- }
-}
-// -- float self-top-up -------------------------------------------------------
-// The float drains toward recipients while LN settlements pile up on the CLN
-// — money that can't reach the float by a normal payment, because the top-up
-// invoice lives on the very node holding it (a self-payment the hold plugin
-// never sees). The refill that works is a circular route: out one channel to
-// a peer, straight back in on another, arriving as a real HTLC the ASP
-// intercepts. Costs the peer's forwarding fee (well under 1%).
-// chunkSat caps one attempt: an ASP receive is funded from the vtxo pool,
-// and asking for more than its fresh buckets hold fails whole ("vtxo pool is
-// empty") where two smaller asks would land. Ticks accumulate to the target.
-const TOPUP = { lowWaterSat: 100_000, targetSat: 500_000, chunkSat: 200_000, ...(CFG.floatTopup || {}) };
-let topupCoolUntil = 0;
-async function autoTopupFloat() {
- if (!fwd || !ln || Date.now() < topupCoolUntil) return;
- const floatSat = fwd.balance().spendableSat;
- const queuedSat = (state.pending || []).reduce((n, p) => n + p.sat, 0);
- if (floatSat >= TOPUP.lowWaterSat && floatSat >= queuedSat) return;
- topupCoolUntil = Date.now() + 10 * 60_000; // one attempt, then cool off
- const info = await ln.call('getinfo');
- const chans = ((await ln.call('listpeerchannels')).channels || [])
- .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected);
- // the widest same-peer pair: out on one channel, back on the other
- let pick = null;
- for (const out of chans) {
- for (const back of chans) {
- if (out === back || out.peer_id !== back.peer_id) continue;
- const cap = Math.min(
- Math.floor((out.spendable_msat ?? 0) / 1000) - 10_000,
- Math.floor((back.receivable_msat ?? 0) / 1000) - 10_000);
- if (!pick || cap > pick.cap) pick = { out, back, cap };
- }
- }
- if (!pick || pick.cap < 20_000) { log('float top-up: no circular route capacity'); return; }
- const amountSat = Math.min(Math.max(TOPUP.targetSat, queuedSat + TOPUP.lowWaterSat) - floatSat, pick.cap, TOPUP.chunkSat);
- const a = await fwd.createLnInvoice(amountSat, 'float auto top-up');
- const dec = await ln.call('decode', { string: a.invoice });
- const gossip = await ln.call('listchannels', { short_channel_id: pick.back.short_channel_id });
- const ret = (gossip.channels || []).find((c) => c.source === pick.back.peer_id);
- if (!ret) throw new Error('no gossip for the return channel');
- const amtMsat = amountSat * 1000;
- const feeMsat = Number(ret.base_fee_millisatoshi) + Math.ceil((amtMsat * ret.fee_per_millionth) / 1e6);
- const finalDelay = dec.min_final_cltv_expiry || 18;
- await ln.call('sendpay', {
- route: [
- { id: pick.out.peer_id, channel: pick.out.short_channel_id, amount_msat: amtMsat + feeMsat, delay: finalDelay + ret.delay, style: 'tlv' },
- { id: info.id, channel: pick.back.short_channel_id, amount_msat: amtMsat, delay: finalDelay, style: 'tlv' },
- ],
- payment_hash: a.paymentHash, bolt11: a.invoice, payment_secret: dec.payment_secret,
- });
- // the HTLC sits held until the float wallet claims — drive it through
- for (let i = 0; i < 30; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await fwd.sync().catch(() => {});
- if (fwd.balance().spendableSat > floatSat) break;
- }
- const after = fwd.balance().spendableSat;
- if (after > floatSat) {
- log(`float self-top-up: +${after - floatSat} sat via circular route (fee ${Math.ceil(feeMsat / 1000)} sat) — float now ${after}`);
- for (const p of state.pending || []) p.next = 0; // the queue can go again now
- persist();
- topupCoolUntil = 0;
- } else {
- log('float self-top-up did not settle — cooling off');
- }
-}
-
-// -- float coin upkeep -------------------------------------------------------
-// The float's coins arrive mostly from the ASP's vtxo pool, whose short
-// lifetimes (~3 days) expire long before the app's monthly renewal cadence —
-// and an expired coin forwarded onward strands money the recipient's wallet
-// refuses (a 100k forward died this way on 2026-08-24). Nothing here runs the
-// app's upkeep layer, so renew explicitly: refresh any coin inside a day of
-// its expiry into a fresh round output.
-let floatRefreshAt = 0;
-const REFRESH_MARGIN_BLOCKS = 144;
-async function floatUpkeep() {
- if (!fwd || Date.now() - floatRefreshAt < 30 * 60_000) return;
- const spendables = (fwd.state?.vtxos || []).filter((v) => v.state === 'spendable');
- // never start a round while another action is still in flight
- if (!spendables.length || (fwd.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
- const tip = await fwd.chain.tipHeight();
- const expiring = spendables.filter((v) =>
- v.expiryHeight && v.expiryHeight - tip < REFRESH_MARGIN_BLOCKS && v.amountSat >= 330);
- if (!expiring.length) return;
- floatRefreshAt = Date.now();
- const sat = expiring.reduce((n, v) => n + v.amountSat, 0);
- log(`float upkeep: refreshing ${expiring.length} coins (${sat} sat) nearing expiry`);
- await fwd.refresh(expiring.map((v) => v.id));
- log(`float upkeep: refresh round complete — float now ${fwd.balance().spendableSat} sat`);
-}
-
-// retry queued forwards (e.g. after the float is topped up). Each failure
-// backs off exponentially (1m → 1h cap): a stuck forward must not fire an
-// offer request — and push-wake the recipient's devices — every minute
-// forever.
-setInterval(async () => {
- if (!fwd) return;
- // always sync: it reads ark mail AND drives pending actions (a board
- // waiting out its confirmations, an ln-recv mid-claim, a queued top-up)
- await fwd.sync().catch(() => {});
- await autoTopupFloat().catch((e) => log('float self-top-up failed: ' + e.message));
- await floatUpkeep().catch((e) => log('float upkeep failed: ' + e.message));
- const q = state.pending || [];
- if (!q.length) return;
- const still = [];
- for (const p of q) {
- if (p.next && Date.now() < p.next) { still.push(p); continue; }
- try { await forward(p.name, p.sat, p.hash); }
- catch (e) {
- log(`forward retry failed for ${p.name} (${p.sat} sat, try ${(p.tries || 0) + 1}): ${e.message}`);
- p.tries = (p.tries || 0) + 1;
- p.next = Date.now() + Math.min(3_600_000, 60_000 * 2 ** Math.min(p.tries - 1, 6));
- still.push(p);
- }
- }
- state.pending = still;
- persist();
-}, 60_000);
-
-// ---------------------------------------------------------------------------
-// claims
-// ---------------------------------------------------------------------------
-
-// NIP-98 HTTP Auth (kind 27235). The signature covers this exact URL, method
-// and body hash, so a captured header is useless anywhere else.
-async function checkNip98(req, url, bodyText) {
- const h = req.headers.get('authorization') || '';
- const m = h.match(/^Nostr\s+(.+)$/i);
- if (!m) return { error: 'missing Nostr authorization' };
- let evt;
- try { evt = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; }
- if (evt.kind !== 27235) return { error: 'auth must be kind 27235' };
- if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth event expired' };
- if (!verifyEvent(evt)) return { error: 'bad signature' };
- const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1];
- if ((tag('method') || '').toUpperCase() !== req.method) return { error: 'method mismatch' };
- let got;
- try { got = new URL(tag('u') || ''); } catch { return { error: 'bad url tag' }; }
- if (got.pathname !== url.pathname) return { error: 'url mismatch' };
- if (bodyText) {
- const want = createHash('sha256').update(bodyText).digest('hex');
- if (tag('payload') !== want) return { error: 'payload hash mismatch' };
- }
- return { pubkey: evt.pubkey };
-}
-
-// npub-prefix names are the free default everyone gets, so they must not be
-// squattable: a name that looks like one may only be claimed by that very
-// identity — or by a key that identity nominated as its manager (the wallet
-// key, which is always available, even offline and in the service worker).
-const npubPrefixOwner = (name) => (/^npub1[a-z0-9]+$/.test(name) ? name : null);
-
-// coinos.io names that belong to existing coinos users are reserved for
-// them until the migration gives those users a way to claim their own.
-async function takenByCoinosUser(domain, name, claimantPubkey = null) {
- if (domain !== 'coinos.io') return false;
- try {
- const r = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`);
- if (r.status !== 200) return false;
- const u = await r.json().catch(() => null);
- if (!u) return false;
- // An existing coinos.io account owns its name. The `migrated` flag once
- // gated this and proved meaningless (poppy got squatted through it) —
- // identity is the proof: only a claim SIGNED by the old account's own
- // nostr key may take the name over.
- return !(u.npub && claimantPubkey && u.npub === npubEncode(claimantPubkey));
- } catch { return true; } // can't verify → refuse rather than squat
-}
-
-const validUri = (u) => typeof u === 'string' && /^bitcoin:/i.test(u) && u.length <= 480
- && !/[\s"\\]/.test(u);
-
-// ---------------------------------------------------------------------------
-// webhooks — POST-on-payment for a name (the coinos.io-style merchant notify)
-// ---------------------------------------------------------------------------
-// A name's owner registers a URL (and optional shared secret); every Lightning
-// settlement for that name then POSTs a small JSON body there. Fires on the
-// SETTLEMENT — the money has arrived on our node for that name — independent
-// of how the onward ark delivery goes.
-//
-// The URL is attacker-supplied and we fetch it from inside our own network,
-// so it is vetted hard: https only, no credentials, and every address the
-// host resolves to must be public unicast (loopback, RFC1918, CGNAT/tailscale
-// 100.64/10, link-local, multicast and their v6 kin all refused) — checked at
-// registration AND before every delivery, since DNS can change between them.
-
-const privV4 = (ip) => {
- const p = ip.split('.').map(Number);
- if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true;
- return p[0] === 0 || p[0] === 10 || p[0] === 127
- || (p[0] === 100 && p[1] >= 64 && p[1] <= 127)
- || (p[0] === 169 && p[1] === 254)
- || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
- || (p[0] === 192 && p[1] === 168) || (p[0] === 192 && p[1] === 0)
- || (p[0] === 198 && (p[1] === 18 || p[1] === 19))
- || p[0] >= 224;
-};
-const privAddr = (addr) => {
- const a = addr.toLowerCase();
- if (a.includes(':')) {
- const v4 = a.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
- if (v4) return privV4(v4[1]);
- return a === '::1' || a === '::' || /^f[cd]/.test(a) || /^fe[89ab]/.test(a) || /^ff/.test(a);
- }
- return privV4(a);
-};
-async function vetWebhookUrl(raw) {
- let u;
- try { u = new URL(String(raw)); } catch { return 'invalid url'; }
- if (u.protocol !== 'https:') return 'url must be https';
- if (u.username || u.password) return 'no credentials in the url';
- if (u.hostname.length > 253 || /^(localhost|.*\.(local|internal|lan|home|arpa))$/i.test(u.hostname)) return 'private host';
- try {
- const { lookup } = await import('node:dns/promises');
- const addrs = await lookup(u.hostname, { all: true });
- if (!addrs.length) return 'host does not resolve';
- for (const a of addrs) if (privAddr(a.address)) return 'host resolves to a private address';
- } catch { return 'host does not resolve'; }
- return null;
-}
-
-// Delivery: immediate attempt, then 30s / 5min / 30min retries, then dropped.
-// The queue is persisted so a restart finishes what it owed.
-const WEBHOOK_BACKOFF = [0, 30_000, 300_000, 1_800_000];
-function queueWebhook(key, payload) {
- const rec = state.names[key];
- if (!rec || !rec.webhook || !rec.webhook.url) return;
- state.webhookQueue = state.webhookQueue || [];
- state.webhookQueue.push({ key, payload, tries: 0, nextAt: Date.now() });
- if (state.webhookQueue.length > 500) state.webhookQueue.splice(0, state.webhookQueue.length - 500);
- persist();
- drainWebhooks();
-}
-let webhookDraining = false;
-async function drainWebhooks() {
- if (webhookDraining) return;
- webhookDraining = true;
- try {
- for (;;) {
- const q = state.webhookQueue || [];
- const now = Date.now();
- const item = q.find((x) => x.nextAt <= now);
- if (!item) break;
- const rec = state.names[item.key];
- const hook = rec && rec.webhook;
- if (!hook || !hook.url) { q.splice(q.indexOf(item), 1); persist(); continue; }
- let outcome;
- const bad = await vetWebhookUrl(hook.url);
- if (bad) outcome = 'refused: ' + bad;
- else {
- try {
- const ctl = new AbortController();
- const t = setTimeout(() => ctl.abort(), 10_000);
- const res = await fetch(hook.url, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ ...item.payload, ...(hook.secret ? { secret: hook.secret } : {}) }),
- redirect: 'error',
- signal: ctl.signal,
- });
- clearTimeout(t);
- outcome = res.ok ? 'ok' : `http ${res.status}`;
- } catch (e) { outcome = 'error: ' + (e.message || 'fetch failed').slice(0, 80); }
- }
- rec.webhook.last = { ts: Date.now(), result: outcome, hash: item.payload.hash };
- if (outcome === 'ok' || outcome.startsWith('refused')) {
- q.splice(q.indexOf(item), 1);
- log(`webhook ${item.key} ${item.payload.hash?.slice(0, 12)}: ${outcome}`);
- } else {
- item.tries += 1;
- if (item.tries >= WEBHOOK_BACKOFF.length) {
- q.splice(q.indexOf(item), 1);
- log(`webhook ${item.key} ${item.payload.hash?.slice(0, 12)}: gave up after ${item.tries} tries (${outcome})`);
- } else {
- item.nextAt = Date.now() + WEBHOOK_BACKOFF[item.tries];
- log(`webhook ${item.key}: ${outcome} — retry ${item.tries}/${WEBHOOK_BACKOFF.length - 1}`);
- }
- }
- persist();
- }
- } finally { webhookDraining = false; }
-}
-setInterval(() => { drainWebhooks().catch(() => {}); }, 30_000);
-
-// crude per-IP limiter
-const rate = new Map();
-const previews = new Map(); // url -> { meta, until }
-// rateExempt: IPs (e.g. the operator's own, while testing sign-ups) the
-// per-minute limits never apply to
-const RATE_EXEMPT = new Set(CFG.rateExempt || []);
-function rateOk(ip, limit = 10) {
- if (RATE_EXEMPT.has(ip)) return true;
- const now = Date.now();
- const arr = (rate.get(ip) || []).filter((t) => t > now - 60_000);
- if (arr.length >= limit) return false;
- arr.push(now);
- rate.set(ip, arr);
- if (rate.size > 1000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); }
- return true;
-}
-
-// ---------------------------------------------------------------------------
-// HTTP
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => {
- if (status >= 400) log(`→ ${status} ${JSON.stringify(body).slice(0, 120)}`);
- return new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type,authorization,x-deepl-key',
- 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
- },
- });
-};
-
-Bun.serve({
- port: CFG.port || 8798,
- async fetch(req, server) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
-
- // ---- point of sale (helpers above settleLoop) ----
- if (url.pathname === '/pos/ws') {
- const a = posAuth(req, url);
- if (!a) return json({ error: 'bad token' }, 401);
- if (server.upgrade(req, { data: { key: a.key, hash: null } })) return undefined;
- return json({ error: 'websocket expected' }, 400);
- }
- // The name's owner mints (POST) or revokes all (DELETE) terminal tokens.
- if (url.pathname === '/pos/token' && (req.method === 'POST' || req.method === 'DELETE')) {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body = {};
- try { body = JSON.parse(bodyText || '{}'); } catch { return json({ error: 'bad body' }, 400); }
- const name = String(body.name || '').toLowerCase();
- const domain = String(body.domain || DOMAIN).toLowerCase();
- const key = `${name}@${domain}`;
- const rec = state.names[key];
- if (!rec) return json({ error: 'unknown name' }, 404);
- if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) return json({ error: 'not your name' }, 403);
- if (req.method === 'DELETE') {
- let n = 0;
- for (const [t, v] of Object.entries(posTokens())) if (v.key === key) { delete posTokens()[t]; n++; }
- persist();
- log(`pos tokens revoked for ${key} (${n})`);
- return json({ ok: true, revoked: n });
- }
- const token = posMintToken(key, body.currency);
- // Currency is the merchant's, not the token's: a new token in CAD moves
- // the terminals already paired to this name to CAD as well.
- const { currency } = posTokens()[token];
- for (const v of Object.values(posTokens())) if (v.key === key) v.currency = currency;
- persist();
- return json({ token, name, domain, currency });
- }
- // Operator bootstrap: mint a terminal token for any name.
- if (url.pathname === '/admin/pos/token' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) return json({ error: 'unauthorized' }, 401);
- const body = await req.json().catch(() => ({}));
- const key = `${String(body.name || '').toLowerCase()}@${String(body.domain || DOMAIN).toLowerCase()}`;
- if (!state.names[key]) return json({ error: 'unknown name' }, 404);
- return json({ token: posMintToken(key, body.currency), key });
- }
- // Everything else under /pos/ is the terminal itself, by bearer token.
- if (url.pathname.startsWith('/pos/')) {
- const a = posAuth(req, url);
- if (!a) return json({ error: 'unauthorized' }, 401);
- const [name] = a.key.split('@');
- if (url.pathname === '/pos/me' && req.method === 'GET') {
- return json({ username: name, address: a.key, currency: a.currency });
- }
- // Ring up a sale: {invoice:{fiat}} (the terminal's shape) or {amount} in sats.
- if (url.pathname === '/pos/invoice' && req.method === 'POST') {
- if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
- if (!ln) return json({ error: 'invoicing is closed right now' }, 503);
- const body = await req.json().catch(() => ({}));
- const inv = body.invoice || body;
- const currency = /^[A-Z]{3}$/.test(String(inv.currency || '')) ? inv.currency : a.currency;
- let rates = null;
- try { rates = await fiatRates(); } catch {}
- const rate = rates ? rates[currency] : null;
- let sat = Math.floor(Number(inv.amount) || 0);
- let fiat = Number(inv.fiat) || null;
- if (!sat) {
- if (!(fiat > 0)) return json({ error: 'fiat or amount required' }, 400);
- if (!rate) return json({ error: `no rate for ${currency}` }, 503);
- sat = Math.round((fiat / rate) * 1e8);
- } else if (rate && fiat == null) {
- fiat = Math.round((sat / 1e8) * rate * 100) / 100;
- }
- if (sat < 1) return json({ error: 'amount too small' }, 400);
- try {
- const r = await ln.call('invoice', {
- amount_msat: sat * 1000,
- label: `pos-${a.key}-${Date.now()}`,
- description: lnurlMeta(a.key),
- deschashonly: true, // LUD-06: the tap's lnurlp metadata hashes to this
- expiry: 900,
- });
- const created = Date.now();
- state.invoices ||= {};
- state.invoices[r.payment_hash] = { key: a.key, bolt11: r.bolt11, ts: created, pos: true };
- posInvoices()[r.payment_hash] = { key: a.key, sat, fiat, currency, rate, bolt11: r.bolt11, created };
- sweepPos();
- persist();
- log(`pos ${a.key}: sale ${sat} sat${fiat ? ` (${fiat} ${currency})` : ''}`);
- return json({ id: r.payment_hash, hash: r.bolt11, amount: sat, fiat, currency, rate, created });
- } catch (e) {
- log('pos invoice failed: ' + e.message);
- return json({ error: 'could not create an invoice' }, 500);
- }
- }
- // Paid sales, newest first, in the shape the terminal's history browser reads.
- if (url.pathname === '/pos/payments' && req.method === 'GET') {
- const limit = Math.min(50, parseInt(url.searchParams.get('limit') || '10', 10) || 10);
- const payments = Object.entries(posInvoices())
- .filter(([, v]) => v.key === a.key && v.paid)
- .sort((x, y) => y[1].paid - x[1].paid)
- .slice(0, limit)
- .map(([hash, v]) => ({ id: hash, amount: v.received ?? v.sat, tip: 0, created: v.paid, currency: v.currency, rate: v.rate }));
- return json({ payments, count: payments.length });
- }
- return json({ error: 'not found' }, 404);
- }
-
- // A link in a post, as the card its page describes for itself (Open
- // Graph). Cached a day either way: a page with nothing to show is asked
- // again only after an hour. Public data; the CDN may cache it too.
- if (url.pathname === '/preview' && req.method === 'GET') {
- if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429);
- const target = (url.searchParams.get('url') || '').trim();
- if (!/^https?:\/\/\S{4,2000}$/.test(target)) return json({ error: 'bad url' }, 400);
- const now = Date.now();
- let hit = previews.get(target);
- if (!hit || now > hit.until) {
- const meta = await fetchPreview(target).catch(() => null);
- hit = { meta, until: now + (meta ? 24 : 1) * 3600_000 };
- previews.set(target, hit);
- if (previews.size > 5000) previews.delete(previews.keys().next().value);
- }
- return new Response(JSON.stringify(hit.meta || {}), {
- headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*',
- 'cache-control': 'public, max-age=' + (hit.meta ? 86400 : 3600) },
- });
- }
-
- // A post in another language, in the reader's (LibreTranslate behind a
- // cache — see translate.js). POST { text, target, source? } →
- // { text, source }; GET /translate/languages lists what it can do.
- if (url.pathname === '/translate/languages' && req.method === 'GET') {
- try { return new Response(JSON.stringify(await languages()), { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=3600' } }); }
- catch { return json({ error: 'translation unavailable' }, 503); }
- }
- if (url.pathname === '/translate' && req.method === 'POST') {
- if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
- let body = null;
- try { body = await req.json(); } catch {}
- if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400);
- // a reader's own DeepL key rides in a header: passed on, never kept or logged
- const dk = (req.headers.get('x-deepl-key') || '').trim();
- try { return json(dk ? await translateDeepl(body.text, body.target, dk) : await translate(body.text, body.target, body.source)); }
- catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); }
- }
-
- if (url.pathname === '/health') {
- return json({
- ok: true, domain: DOMAIN, names: Object.keys(state.names).length,
- offers: !!ln, floatSat: fwd ? fwd.balance().spendableSat : null,
- floatAddress: fwd ? fwd.address() : null,
- pendingForwards: (state.pending || []).length,
- });
- }
-
- // Routing-fee quote for an outgoing lightning payment. The wallet locks
- // amount + this fee into its HTLC and the ASP hands the whole surplus to
- // CLN as the routing budget, so the number returned here is exactly what
- // the user pays — zero for invoices minted on our own node (every
- // coinos-to-coinos payment) and for direct peers. Informational only:
- // a wrong quote fails the payment (refunded in full) or overpays a route,
- // it can never spend more than the user chose to lock.
- if (url.pathname === '/lnquote' && req.method === 'GET') {
- // network=mutinynet quotes from the staging ASP's CLN (the same node
- // lnMut points at) so staging wallets get the quoted-route-fee model
- // mainnet has, instead of a flat schedule
- const lnq = (url.searchParams.get('network') || '') === 'mutinynet' ? lnMut : ln;
- if (!lnq) return json({ error: 'no lightning backend' }, 503);
- if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429);
- try {
- const invoice = (url.searchParams.get('invoice') || '').trim().toLowerCase();
- if (!invoice || invoice.length > 4000) return json({ error: 'invoice required' }, 400);
- const dec = await lnq.call('decode', { string: invoice });
- if (dec.valid === false) return json({ error: 'invalid invoice' }, 400);
- const dest = dec.payee || dec.invoice_node_id;
- const amountMsat = Math.floor(Number(url.searchParams.get('amount_msat')) || 0)
- || Number(dec.amount_msat || dec.invoice_amount_msat || 0);
- if (!dest) return json({ error: 'no destination in invoice' }, 400);
- if (!amountMsat || amountMsat < 0) return json({ error: 'amount required' }, 400);
- const our = await ourNodeId(lnq);
- if (dest === our) return json({ feeMsat: 0, feeSat: 0, direct: true });
- const finalCltv = dec.min_final_cltv_expiry || 18;
- try {
- const feeMsat = await routeFeeMsat(our, dest, amountMsat, finalCltv, lnq);
- return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false });
- } catch (e) {
- // A bolt12 invoice with blinded paths: its node id is a blinded
- // key no router knows. Quote = route to a path's public entry
- // node + the blinded segment's advertised payinfo fees.
- const paths = dec.invoice_paths || [];
- let best = null;
- for (const p of paths) {
- const pay = p.payinfo || {};
- const entry = p.first_node_id;
- if (!entry) continue;
- const f = (pay.fee_base_msat || 0) + Math.ceil(amountMsat * (pay.fee_proportional_millionths || 0) / 1_000_000);
- try {
- const toEntry = entry === our ? 0 : await routeFeeMsat(our, entry, amountMsat + f, finalCltv, lnq);
- if (best == null || toEntry + f < best) best = toEntry + f;
- } catch {}
- }
- if (best != null) return json({ feeMsat: best, feeSat: quoteSat(best), direct: false, blinded: true });
- // Private bolt11 recipient: not in gossip, reachable only via the
- // invoice's route hints. Quote = route to the hint's public entry
- // node + the hint hops' advertised fees.
- const hint = (dec.routes || [])[0];
- if (!hint || !hint.length) throw e;
- let hintFeeMsat = 0;
- let carried = amountMsat;
- for (const hop of [...hint].reverse()) {
- const f = (hop.fee_base_msat || 0) + Math.ceil(carried * (hop.fee_proportional_millionths || 0) / 1_000_000);
- hintFeeMsat += f; carried += f;
- }
- const entry = hint[0].pubkey;
- const toEntry = entry === our ? 0
- : await routeFeeMsat(our, entry, amountMsat + hintFeeMsat, finalCltv, lnq);
- const feeMsat = toEntry + hintFeeMsat;
- return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false, hinted: true });
- }
- } catch (e) {
- return json({ error: e.message || 'no route found' }, 404);
- }
- }
-
- // Recent LN settlements forwarded to the caller's name(s) as float
- // arkoors — the wallet matches them against its receive movements to
- // mark which ones actually arrived over Lightning. NIP-98 authed and
- // owner-only: this is a feed of incoming amounts.
- if (url.pathname === '/lnreceipts' && req.method === 'GET') {
- if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429);
- const a = await checkNip98(req, url, '');
- if (a.error) return json({ error: a.error }, 401);
- const receipts = [];
- for (const [key, rec] of Object.entries(state.names)) {
- if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) continue;
- for (const r of (state.lnReceipts || {})[key] || []) {
- receipts.push({ name: key, sat: r.sat, ts: r.ts, hash: r.hash || null });
- }
- }
- receipts.sort((x, y) => y.ts - x.ts);
- return json({ receipts: receipts.slice(0, 100) });
- }
-
- // Prefix search over registered names — powers recipient search in the
- // wallet's DMs. Public data (every name is a public DNS record).
- if (url.pathname === '/search' && req.method === 'GET') {
- if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429);
- const q = (url.searchParams.get('q') || '').toLowerCase().trim();
- if (q.length < 2 || q.length > 30) return json({ results: [] });
- const results = Object.entries(state.names)
- .filter(([key]) => key.split('@')[0].startsWith(q))
- .sort(([a], [b]) => a.length - b.length || (a < b ? -1 : 1))
- .slice(0, 10)
- .map(([key, r]) => ({ address: key, name: key.split('@')[0], pubkey: r.pubkey }));
- return json({ results });
- }
-
- // A user's own BOLT 12 offers with a custom memo — what a mining pool or
- // payroll wants to send to. Settled payments ride the same forwarder as
- // the name's default offer, so they land in the user's Spending balance.
- if (url.pathname === '/offer' && (req.method === 'POST' || req.method === 'GET')) {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = req.method === 'POST' ? await req.text() : '';
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- const key = Object.keys(state.names)
- .find((k) => state.names[k].pubkey === a.pubkey || state.names[k].manager === a.pubkey);
- if (!key) return json({ error: 'claim a name first' }, 400);
- state.offers = state.offers || {};
- if (req.method === 'GET') {
- return json({ offers: Object.entries(state.offers)
- .filter(([, o]) => o.key === key)
- .map(([offerId, o]) => ({ offerId, memo: o.memo, bolt12: o.bolt12, created: o.created })) });
- }
- if (!ln) return json({ error: 'offers unavailable' }, 503);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const memo = String(body.memo || '').slice(0, 120).trim();
- const mine = Object.entries(state.offers).filter(([, o]) => o.key === key);
- const dup = mine.find(([, o]) => o.memo === memo);
- if (dup) return json({ ok: true, offerId: dup[0], memo, bolt12: dup[1].bolt12, existing: true });
- if (mine.length >= 10) return json({ error: 'too many offers' }, 400);
- let o;
- try {
- // `issuer` keeps two users' identical memos from collapsing onto one
- // CLN offer (it dedupes by the offer's contents).
- o = await ln.call('offer', { amount: 'any', description: memo || key, issuer: key });
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- state.offers[o.offer_id] = { key, memo, bolt12: o.bolt12, created: Date.now() };
- persist();
- log(`offer for ${key}: ${memo ? JSON.stringify(memo) : '(no memo)'}`);
- return json({ ok: true, offerId: o.offer_id, memo, bolt12: o.bolt12 });
- }
-
- // --- NIP-05 --------------------------------------------------------
- // Every claimed name doubles as a nostr identifier: name@domain verifies
- // against the owner's key. Public data, CORS comes with json().
- if ((url.pathname === '/.well-known/nostr.json' || url.pathname === '/nostr.json') && req.method === 'GET') {
- const name = (url.searchParams.get('name') || '').toLowerCase();
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- if (!NAME_RE.test(name)) return json({ names: {} });
- const rec = state.names[`${name}@${domain}`];
- return json(rec ? { names: { [name]: rec.pubkey } } : { names: {} });
- }
-
- // Availability / lookup. Public data (it's DNS).
- const m = url.pathname.match(/^\/name\/([a-z0-9._-]{1,30})$/);
- if (m && req.method === 'GET') {
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- const rec = state.names[`${m[1]}@${domain}`];
- // granted: the record's holder is where the legacy coinos.io account's
- // own session sent the name (/migrate-grant) — coinos.io's v3 bridge
- // forwards that account's incoming payments here only on such proof
- if (rec) {
- const g = domain === 'coinos.io' && (state.migrations || {})[`${m[1]}@coinos.io`];
- const keys = [rec.pubkey, rec.manager].filter(Boolean);
- const granted = !!g && (keys.includes(g.pubkey) || (!!g.manager && keys.includes(g.manager)));
- return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null, lno: rec.lno || null, granted });
- }
- const invalid = !NEW_NAME_RE.test(m[1]) && !npubPrefixOwner(m[1]);
- const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || invalid || await takenByCoinosUser(domain, m[1]);
- return json({ name: m[1], domain, taken: reserved, reserved, ...(invalid ? { invalid: true } : {}) });
- }
-
- // Which name(s) does a wallet key own? Lets an imported seed find its
- // username again without the user retyping it. Public data (DNS is public).
- const pm = url.pathname.match(/^\/pubkey\/([0-9a-f]{64})$/);
- if (pm && req.method === 'GET') {
- // optional domain filter, so a staging claim can't shadow a mainnet
- // name (or vice versa) when a wallet asks who it is
- const domFilter = (url.searchParams.get('domain') || '').toLowerCase();
- const mine = Object.entries(state.names)
- .filter(([k, r]) => r.pubkey === pm[1] && (!domFilter || k.endsWith('@' + domFilter)))
- // a custom name outranks the free npub placeholder regardless of
- // which record is fresher — a placeholder re-claimed on a new device
- // must not shadow the name the user actually chose
- .sort((a, b) => {
- const ph = ([k]) => (/^npub1/.test(k.split('@')[0]) ? 1 : 0);
- return ph(a) - ph(b) || (b[1].updated || 0) - (a[1].updated || 0);
- });
- if (!mine.length) return json({});
- const [key, r] = mine[0];
- const [name, domain] = key.split('@');
- return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null, lno: r.lno || null });
- }
-
- // --- hats ------------------------------------------------------------
-
- // Batched display lookup: which hat is each of these heads wearing?
- // Public data (a hat is worn in public by construction).
- if (url.pathname === '/hats' && req.method === 'GET') {
- const net = hatNetOf(url.searchParams.get('net'));
- const pks = (url.searchParams.get('pks') || '')
- .split(',').filter((p) => /^[0-9a-f]{64}$/.test(p)).slice(0, 100);
- const hats = {};
- for (const pk of pks) hats[pk] = hatRec(pk, net).equipped;
- return json({ hats });
- }
-
- // The shop view: everything one pubkey owns, plus the price list (the
- // client shows what the server would actually charge).
- const hm = url.pathname.match(/^\/hats\/([0-9a-f]{64})$/);
- if (hm && req.method === 'GET') {
- return json({ ...hatRec(hm[1], hatNetOf(url.searchParams.get('net'))), prices: HAT_PRICES });
- }
-
- // Operator gift: drop a hat into someone's rack (x-admin-token). Takes
- // hex or npub; giftable ids only — the crown stays singular. The gift
- // sits in the rack rather than force-equipping over a paid hat.
- if (url.pathname === '/hats/grant' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- let b;
- try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); }
- let pk = String(b.pubkey || '');
- if (/^npub1/.test(pk)) {
- try { pk = nip19Decode(pk).data; } catch { return json({ error: 'bad npub' }, 400); }
- }
- if (!/^[0-9a-f]{64}$/.test(pk)) return json({ error: 'bad pubkey' }, 400);
- const hat = String(b.hat || '');
- // giftable but never sold: the beta hard hat, and the knight's helm
- if (!(hat in HAT_PRICES) && !['construction', 'knight'].includes(hat)) return json({ error: 'no such hat' }, 400);
- const net = hatNetOf(b.net);
- // the operator may say the gift goes straight on the head (a hat made
- // for this one person); by default it only crowns a bare one
- grantHat(pk, hat, net, { equip: b.equip === true });
- persist();
- log(`hat gifted: ${hat} (${net}) to ${pk.slice(0, 12)}${b.equip === true ? ', worn' : ''}`);
- return json(hatRec(pk, net));
- }
-
- if (url.pathname === '/hats/invoice' && req.method === 'POST') {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const hat = String(body.hat || '');
- const net = hatNetOf(body.net);
- const sat = HAT_PRICES[hat];
- if (!sat) return json({ error: 'no such hat' }, 400);
- if (hatRec(a.pubkey, net).owned.includes(hat)) return json({ error: 'you already own that hat' }, 400);
- const node = hatLn(net);
- if (!node) return json({ error: 'the hat stand is closed right now' }, 503);
- try {
- const inv = await node.call('invoice', {
- amount_msat: sat * 1000,
- label: `hat-${hat}-${a.pubkey.slice(0, 8)}-${Date.now()}`,
- description: `coinos hat: ${hat}`,
- expiry: 900,
- });
- state.hatInvoices ||= {};
- state.hatInvoices[inv.payment_hash] = { pubkey: a.pubkey, hat, sat, net, ts: Date.now() };
- for (const [h2, v] of Object.entries(state.hatInvoices)) {
- if (Date.now() - v.ts > 7 * 86400_000) delete state.hatInvoices[h2];
- }
- persist();
- log(`hat invoice: ${hat} (${sat} sat, ${net}) for ${a.pubkey.slice(0, 12)}`);
- return json({ invoice: inv.bolt11, paymentHash: inv.payment_hash, sat });
- } catch (e) {
- log('hat invoice failed: ' + e.message);
- return json({ error: 'could not create an invoice' }, 500);
- }
- }
-
- // The buyer pings us right after paying, so the hat appears without
- // waiting on the settle loop. Idempotent with it: whoever runs first
- // grants, the other finds it done.
- if (url.pathname === '/hats/claim' && req.method === 'POST') {
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const hash = String(body.paymentHash || '');
- const hat = String(body.hat || '');
- const net = hatNetOf(body.net);
- const pending = (state.hatInvoices || {})[hash];
- if (!pending) {
- // the settle loop may have beaten us to it
- if (hat && hatRec(a.pubkey, net).owned.includes(hat)) return json(hatRec(a.pubkey, net));
- return json({ error: 'unknown invoice' }, 404);
- }
- if (pending.pubkey !== a.pubkey) return json({ error: 'not your invoice' }, 403);
- const node = hatLn(hatNetOf(pending.net));
- if (!node) return json({ error: 'the hat stand is closed right now' }, 503);
- try {
- const r = await node.call('listinvoices', { payment_hash: hash });
- if ((r.invoices || [])[0]?.status !== 'paid') return json({ error: 'invoice not paid yet' }, 402);
- } catch (e) {
- return json({ error: 'could not check the invoice' }, 500);
- }
- grantHat(pending.pubkey, pending.hat, hatNetOf(pending.net));
- delete state.hatInvoices[hash];
- persist();
- log(`hat sold (claim): ${pending.hat} (${hatNetOf(pending.net)}) to ${pending.pubkey.slice(0, 12)}`);
- return json(hatRec(a.pubkey, hatNetOf(pending.net)));
- }
-
- if (url.pathname === '/hats/equip' && req.method === 'POST') {
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body;
- try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const hat = body.hat ? String(body.hat) : null;
- const net = hatNetOf(body.net);
- if (hat && !hatRec(a.pubkey, net).owned.includes(hat)) return json({ error: 'you do not own that hat' }, 403);
- const r = hatStore(net)[a.pubkey] ||= { owned: [], equipped: null };
- r.equipped = hat;
- persist();
- return json(hatRec(a.pubkey, net));
- }
-
- // --- LNURL-pay (LUD-06/16, NIP-57 zaps) ------------------------------
- const lm = url.pathname.match(/^\/(?:\.well-known\/lnurlp|lnurlp)\/([a-z0-9._-]{1,30})$/i);
- if (lm && req.method === 'GET') {
- const name = lm[1].toLowerCase();
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- const rec = state.names[`${name}@${domain}`];
- if (!rec) return json({ status: 'ERROR', reason: 'unknown address' }, 404);
- const address = `${name}@${domain}`;
- // A terminal that just rang up a sale: the tap pays exactly that
- // invoice (coinos.io's /p/<name> rule — newest unpaid sale under five
- // minutes old). Otherwise the payer picks the amount.
- const pos = posPendingInvoice(address);
- if (pos) return json({
- tag: 'payRequest',
- callback: `${PUBLIC_BASE}/lnurlp/${name}/cb?domain=${encodeURIComponent(domain)}&pos=${pos.hash}`,
- minSendable: pos.sat * 1000,
- maxSendable: pos.sat * 1000,
- metadata: lnurlMeta(address),
- commentAllowed: 0,
- allowsNostr: false,
- });
- return json({
- tag: 'payRequest',
- callback: `${PUBLIC_BASE}/lnurlp/${name}/cb?domain=${encodeURIComponent(domain)}`,
- minSendable: 1000,
- maxSendable: 500000000,
- metadata: lnurlMeta(address),
- commentAllowed: 200,
- allowsNostr: true,
- nostrPubkey: SERVICE_PK,
- });
- }
-
- const cb = url.pathname.match(/^\/lnurlp\/([a-z0-9._-]{1,30})\/cb$/i);
- if (cb && req.method === 'GET') {
- const name = cb[1].toLowerCase();
- const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
- const key = `${name}@${domain}`;
- const rec = state.names[key];
- if (!rec) return json({ status: 'ERROR', reason: 'unknown address' }, 404);
- const msat = parseInt(url.searchParams.get('amount') || '0', 10);
- // a POS sale: hand back the invoice the terminal minted, nothing else
- const posHash = url.searchParams.get('pos');
- if (posHash) {
- const v = posInvoices()[posHash];
- if (!v || v.key !== key || v.paid || Date.now() - v.created > POS_INVOICE_TTL)
- return json({ status: 'ERROR', reason: 'that sale has expired — tap again' }, 410);
- if (msat !== v.sat * 1000) return json({ status: 'ERROR', reason: 'amount must match the sale' }, 400);
- return json({ pr: v.bolt11, routes: [] });
- }
- if (!msat || msat < 1000) return json({ status: 'ERROR', reason: 'amount too small' }, 400);
- const sat = Math.floor(msat / 1000);
- const zap = url.searchParams.get('nostr') || null;
- const comment = (url.searchParams.get('comment') || '').slice(0, 200);
-
- // 1. the recipient's own wallet or node service, if it is listening
- const clink = clinkDest(rec);
- if (clink) {
- // staging wallets mint signet invoices — only mainnet is checked
- const net = domain === 'staging.coinos.io' ? null : 'mainnet';
- // asked twice: a wallet that handed back a stale invoice (its Ark
- // server re-serving an old payment hash) usually gets it right on
- // the next index
- for (let attempt = 0; attempt < 2; attempt++) {
- const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: comment, zap });
- if (!pr) break;
- if (usableInvoice(pr, sat, net)) {
- let hash = '', exp = '';
- try { const d = decodeBolt11(pr); hash = String(d.paymentHash || '').slice(0, 12); exp = d.expiresAt ? new Date(d.expiresAt).toISOString().slice(0, 16) : ''; } catch {}
- log(`lnurl ${key}: recipient minted ${sat} sat (hash ${hash}, expires ${exp})`);
- return json({ pr, routes: [] });
- }
- let why = 'unusable';
- try { const d = decodeBolt11(pr); if (d.expiresAt && d.expiresAt < Date.now() + 60_000) why = 'expired ' + new Date(d.expiresAt).toISOString().slice(0, 16); } catch {}
- log(`lnurl ${key}: recipient returned an ${why} invoice${attempt ? ' again — minting instead' : ' — asking again'}`);
- }
- }
- // Staging names are mutinynet wallets: only their own wallet can mint a
- // right-network invoice. Minting on our mainnet node would take real
- // sats for play money and strand them (the forwarder can't deliver to
- // a testnet ark address).
- if (domain === 'staging.coinos.io') return json({ status: 'ERROR', reason: 'recipient is offline' }, 503);
- // 2. our node, forwarded on settle
- if (!ln || !fwd) return json({ status: 'ERROR', reason: 'recipient is offline' }, 503);
- try {
- const description = zap || lnurlMeta(key);
- const inv = await ln.call('invoice', {
- amount_msat: msat,
- label: `lnurl-${key}-${Date.now()}`,
- description,
- deschashonly: true, // LUD-06: the invoice carries sha256(metadata)
- expiry: 900,
- });
- state.invoices = state.invoices || {};
- state.invoices[inv.payment_hash] = { key, zap, bolt11: inv.bolt11, ts: Date.now() };
- // forget invoices nobody paid
- // Keep mappings as long as the invoice could possibly settle: a 1h
- // sweep once erased a mapping while the settle loop was catching up
- // after downtime, stranding a paid 1000 sats unattributed (found
- // again only via the description_hash). CLN invoices default to a
- // 7-day expiry — match it.
- for (const [h, v] of Object.entries(state.invoices)) {
- if (Date.now() - v.ts > 7 * 86400_000) delete state.invoices[h];
- }
- persist();
- log(`lnurl ${key}: issued ${sat} sat (forwarding on settle)`);
- return json({ pr: inv.bolt11, routes: [] });
- } catch (e) {
- log('lnurl invoice failed: ' + e.message);
- return json({ status: 'ERROR', reason: 'could not create an invoice' }, 500);
- }
- }
-
- // Top up the float over Lightning: it mints an invoice against its own
- // ark balance, which anyone (in practice, the operator's node) can pay.
- // Guarded by a token from the config file — this endpoint is public.
- // Chain relay for wallets whose networks can't reach any public esplora
- // (one tester's ISP blackholes mempool.space AND blockstream — every
- // chain-touching wallet feature died for it). A narrow passthrough to
- // mempool.space: only the four paths the ark manager speaks, the tip
- // briefly cached so polling wallets cost one upstream call per few
- // seconds, not one each.
- if (url.pathname.startsWith('/esplora/')) {
- const sub = url.pathname.slice('/esplora'.length);
- const okPath = sub === '/blocks/tip/height'
- || /^\/tx\/[0-9a-f]{64}\/(hex|status)$/.test(sub)
- || (sub === '/tx' && req.method === 'POST');
- if (!okPath) return json({ error: 'not proxied' }, 404);
- if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429);
- try {
- if (sub === '/blocks/tip/height' && globalThis._tipCache && Date.now() - globalThis._tipCache.t < 5000) {
- return new Response(globalThis._tipCache.v, { headers: { 'access-control-allow-origin': '*' } });
- }
- const r = await fetch('https://mempool.space/api' + sub,
- req.method === 'POST' ? { method: 'POST', body: await req.text() } : undefined);
- const body = await r.text();
- if (sub === '/blocks/tip/height' && r.ok) globalThis._tipCache = { v: body, t: Date.now() };
- return new Response(body, {
- status: r.status,
- headers: {
- 'access-control-allow-origin': '*',
- 'content-type': r.headers.get('content-type') || 'text/plain',
- },
- });
- } catch {
- return json({ error: 'upstream unreachable' }, 502);
- }
- }
-
- // Legacy migration hand-off. coinos.io's migrate action (running with
- // the OLD account's authenticated session — that session IS the proof of
- // ownership) tells us which v3 identity may claim the released name.
- // Without this the claim guard has nothing to go on: identity-only proof
- // (the old account's npub signing the claim) never matches a v3 wallet's
- // key, so every migrated claim 409'd forever. The grant names either the
- // destination address (we look up its record) or a pubkey directly (the
- // operator-rescue form).
- if (url.pathname === '/migrate-grant' && req.method === 'POST') {
- const tok = (req.headers.get('authorization') || '').replace(/^Bearer\s+/i, '');
- if (!CFG.migrateToken || tok !== CFG.migrateToken) return json({ error: 'unauthorized' }, 401);
- let b;
- try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); }
- const name = String(b.name || '').toLowerCase();
- if (!NAME_RE.test(name)) return json({ error: 'bad name' }, 400);
- let pubkey = /^[0-9a-f]{64}$/.test(String(b.pubkey || '')) ? b.pubkey : null;
- let manager = null;
- if (!pubkey && b.to) {
- const [tn, td] = String(b.to).toLowerCase().split('@');
- const rec = state.names[`${tn}@${td}`];
- if (!rec) return json({ error: 'destination is not registered here' }, 404);
- pubkey = rec.pubkey;
- manager = rec.manager || null;
- }
- if (!pubkey) return json({ error: 'no destination' }, 400);
- (state.migrations ||= {})[`${name}@coinos.io`] = { pubkey, manager, ts: Date.now() };
- persist();
- log(`migrate-grant: ${name}@coinos.io -> ${pubkey.slice(0, 8)}`);
- return json({ ok: true });
- }
-
- if (url.pathname === '/admin/topup' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- const body = await req.json().catch(() => ({}));
- const sat = Math.floor(body.sat || 0);
- if (!sat || sat < 1000) return json({ error: 'amount too small' }, 400);
- try {
- const a = await fwd.createLnInvoice(sat, 'names float top-up');
- log(`float top-up invoice for ${sat} sat`);
- return json({ invoice: a.invoice, paymentHash: a.paymentHash });
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- }
-
- // Board on-chain sats into the float: returns a P2TR funding address.
- // The funding tx must pay EXACTLY the requested amount to it AT VOUT 0
- // (the board outpoint is fixed to index 0), then /admin/board/complete
- // with the txid; confirmations are waited out by the sync loop.
- if (url.pathname === '/admin/board' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- const body = await req.json().catch(() => ({}));
- const sat = Math.floor(body.sat || 0);
- if (!sat || sat < 10_000) return json({ error: 'amount too small' }, 400);
- try {
- const b = await fwd.startBoard(sat);
- log(`float board started: ${sat} sat -> ${b.fundingAddress} (fee ${b.feeSat} sat)`);
- return json(b);
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- }
- if (url.pathname === '/admin/board/complete' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- const body = await req.json().catch(() => ({}));
- if (!body.actionId || !/^[0-9a-f]{64}$/.test(body.txid || '')) {
- return json({ error: 'actionId and txid required' }, 400);
- }
- try {
- const a = await fwd.completeBoard(body.actionId, body.txid);
- return json({ ok: true, step: a.step, confs: a.confs ?? 0, needConfs: a.needConfs ?? null });
- } catch (e) {
- return json({ error: e.message }, 500);
- }
- }
-
- // Drive an in-flight lightning receive forward (top-ups settle async).
- if (url.pathname === '/admin/drive' && req.method === 'POST') {
- if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
- return json({ error: 'unauthorized' }, 401);
- }
- if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
- for (const a of fwd.pendingActions()) { await fwd.driveLn(a.id).catch(() => {}); }
- await fwd.sync().catch(() => {});
- return json({ ok: true, floatSat: fwd.balance().spendableSat });
- }
-
- if (url.pathname === '/register' && req.method === 'POST') {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let claim;
- try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const auth = { pubkey: a.pubkey };
- const name = String(claim.name || '').toLowerCase();
- const domain = String(claim.domain || 'halwallet.app').toLowerCase();
- if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400);
- if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400);
- // A migration grant (the legacy account's authenticated session told
- // us where the name is going) trumps BOTH the reserved list and the
- // legacy reservation: a reserved word that is also a real coinos.io
- // account (the house account `coinos` itself) can only ever arrive
- // here through a migration, and the identity check below can never
- // pass for a v3 wallet key. Reserved without a grant stays reserved.
- const grant = (state.migrations || {})[`${name}@coinos.io`];
- const granted = domain === 'coinos.io' && grant
- && (grant.pubkey === auth.pubkey || (grant.manager && grant.manager === auth.pubkey));
- if (RESERVED.has(name) && !granted) return json({ error: 'name is reserved' }, 400);
- if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400);
-
- const key = `${name}@${domain}`;
- const existing = state.names[key];
- // owner OR the manager key the owner nominated may update a record
- if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) {
- return json({ error: 'name is taken' }, 409);
- }
- if (!existing && !granted && !npubPrefixOwner(name) && !NEW_NAME_RE.test(name)) return json({ error: NEW_NAME_ERR }, 400);
- if (!existing && !granted && await takenByCoinosUser(domain, name, auth.pubkey)) return json({ error: 'name is taken' }, 409);
- // an npub-shaped name belongs to that identity alone
- if (npubPrefixOwner(name)) {
- const managerOk = existing && existing.manager === auth.pubkey;
- if (!npubEncode(auth.pubkey).startsWith(name) && !managerOk) {
- return json({ error: 'that name belongs to another Nostr identity' }, 403);
- }
- }
- if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400);
- // Delivery noffer: absent = keep, '' = clear, a code = point Lightning
- // delivery at the owner's own CLINK service (their node's invoice
- // minter). Decoded here so a typo can't become a delivery black hole.
- let noffer = existing?.noffer;
- if (claim.noffer !== undefined) {
- if (!claim.noffer) noffer = undefined;
- else {
- try {
- const d = decodeNoffer(String(claim.noffer));
- if (!/^wss?:\/\/./.test(d.relay || '')) throw new Error('offer code names no relay');
- noffer = { pubkey: d.pubkey, relay: d.relay, offerId: d.offerId || '', raw: String(claim.noffer) };
- } catch (e) { return json({ error: 'bad noffer: ' + e.message }, 400); }
- }
- }
- // Own BOLT 12 offer: same absent/''/set semantics. When set, the DNS
- // record carries THEIR lno — senders' fetchinvoice goes straight to
- // their node and no money ever touches this service.
- let lno = existing?.lno;
- if (claim.lno !== undefined) {
- if (!claim.lno) lno = undefined;
- else {
- try {
- const d = decodeOffer(String(claim.lno).trim());
- if (domain !== 'staging.coinos.io' && d.network && d.network !== 'mainnet') {
- throw new Error(`offer is for ${d.network}`);
- }
- lno = String(claim.lno).trim().toLowerCase();
- } catch (e) { return json({ error: 'bad offer: ' + e.message }, 400); }
- }
- }
- // A corrupt ark address recorded here is a payment black hole: forwards
- // fail forever while the sats sit on the node (goyslop's 190k sat sat
- // behind an invalid checksum). Refuse it at the door.
- const arkDest = arkParamOf(claim.uri);
- if (arkDest) {
- try { decodeAddress(arkDest); }
- catch { return json({ error: 'the ark address in the uri does not decode' }, 400); }
- }
-
- // The record's lno=: the owner's own offer when they set one, else a
- // static offer on our node with the settle forwarder behind it.
- let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null;
- if (lno) {
- // their node answers fetchinvoice directly now — retire our stand-in
- if (offer && ln) await ln.call('disableoffer', { offer_id: offer.offerId }).catch(() => {});
- offer = null;
- } else if (!offer) {
- try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); }
- }
- const pubLno = lno || offer?.bolt12;
- const published = pubLno ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${pubLno}` : claim.uri;
-
- const recordId = await cfWrite(name, domain, published, existing?.recordId);
- state.names[key] = {
- ...registrationIdentity({ existing, signer: auth.pubkey, manager: claim.manager, grant, granted }),
- uri: published, recordId, updated: Date.now(), domain,
- // the wallet's CLINK offer key, so LNURL can ask it for invoices
- offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk,
- // the owner's own CLINK service / BOLT 12 offer, when they set one
- ...(noffer ? { noffer } : {}),
- ...(lno ? { lno } : {}),
- // a registered webhook survives record updates — it's cleared only
- // through its own endpoint
- ...(existing?.webhook ? { webhook: existing.webhook } : {}),
- offerId: offer?.offerId, bolt12: offer?.bolt12,
- };
- persist();
- log(`${existing ? 'updated' : 'registered'} ${key} for ${auth.pubkey.slice(0, 12)}`);
- if (!existing) sendWelcome(state.names[key].pubkey, [state.names[key].manager]).catch(() => {});
- return json({ ok: true, name, address: key, record: recordName(name, domain) });
- }
-
- // Webhook management: the name's owner (or nominated manager) points a
- // URL at their own system and every settled payment for the name POSTs
- // there. GET shows the current config plus the last delivery's outcome;
- // an empty/absent url on POST (or DELETE) clears it.
- if (url.pathname === '/webhook' && (req.method === 'POST' || req.method === 'DELETE' || req.method === 'GET')) {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = req.method === 'GET' ? '' : await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let body = {};
- if (bodyText) { try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } }
- const name = String((req.method === 'GET' ? url.searchParams.get('name') : body.name) || '').toLowerCase();
- const domain = String((req.method === 'GET' ? url.searchParams.get('domain') : body.domain) || 'coinos.io').toLowerCase();
- const key = `${name}@${domain}`;
- const rec = state.names[key];
- if (!rec) return json({ error: 'unknown name' }, 404);
- if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) return json({ error: 'not your name' }, 403);
- if (req.method === 'GET') {
- return json({ ok: true, name, domain, webhook: rec.webhook
- ? { url: rec.webhook.url, secret: rec.webhook.secret || null, set: rec.webhook.set, last: rec.webhook.last || null }
- : null });
- }
- if (req.method === 'DELETE' || !body.url) {
- delete rec.webhook;
- persist();
- log(`webhook cleared for ${key}`);
- return json({ ok: true, webhook: null });
- }
- const rawUrl = String(body.url).slice(0, 512);
- const bad = await vetWebhookUrl(rawUrl);
- if (bad) return json({ error: bad }, 400);
- const secret = body.secret != null ? String(body.secret).slice(0, 128) : undefined;
- rec.webhook = { url: rawUrl, ...(secret ? { secret } : {}), set: Date.now() };
- persist();
- log(`webhook set for ${key} → ${new URL(rawUrl).hostname}`);
- return json({ ok: true, webhook: { url: rawUrl, secret: secret || null } });
- }
-
- if (url.pathname === '/register' && req.method === 'DELETE') {
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const bodyText = await req.text();
- const a = await checkNip98(req, url, bodyText);
- if (a.error) return json({ error: a.error }, 401);
- let claim;
- try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
- const auth = { pubkey: a.pubkey };
- const name = String(claim.name || '').toLowerCase();
- const domain = String(claim.domain || 'halwallet.app').toLowerCase();
- const key = `${name}@${domain}`;
- const existing = state.names[key];
- if (!existing) return json({ error: 'unknown name' }, 404);
- if (existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) return json({ error: 'not your name' }, 403);
- await cfDelete(domain, existing.recordId).catch(() => {});
- if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {});
- delete state.names[key];
- persist();
- log(`released ${key}`);
- return json({ ok: true });
- }
-
- return json({ error: 'not found' }, 404);
- },
- // POS terminals: one socket per device. `subscribe` names the sale it is
- // waiting on; a `payment` message arrives when that sale settles.
- // Heartbeats and anything else are ignored.
- websocket: {
- open(ws) {
- posSockets.add(ws);
- ws.send(JSON.stringify({ type: 'connected', data: ws.data.key }));
- },
- message(ws, raw) {
- let m;
- try { m = JSON.parse(String(raw)); } catch { return; }
- if (m?.type !== 'subscribe') return;
- const id = String(m.data?.id || m.data || '');
- const v = posInvoices()[id];
- if (!v || v.key !== ws.data.key) return;
- ws.data.hash = id;
- // a sale that settled between the mint and this subscribe is still a sale
- if (v.paid) { try { ws.send(posPaymentMsg(id, v)); } catch {} }
- },
- close(ws) { posSockets.delete(ws); },
- },
-});
-
-settleLoop().catch((e) => log('settle loop died:', e.message));
-log(`names registrar for ${DOMAIN} on :${CFG.port || 8798} — ${Object.keys(state.names).length} name(s), offers ${ln ? 'on' : 'off'}, forwarder ${fwd ? 'on' : 'off'}`);
diff --git a/names/translate.js b/names/translate.js
deleted file mode 100644
index 36ab8cf..0000000
--- a/names/translate.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// Post translation for coinos v3: a thin, cached front for a LibreTranslate
-// container on the same docker network. Notes are public, so nothing private
-// passes through here; the cache keys on the text itself, so a popular post
-// is translated once whoever asks.
-import { createHash } from 'node:crypto';
-import { translatable, translatePieces } from '../src/translate-split.js';
-
-const LT = process.env.LT_URL || 'http://libretranslate:5000';
-const MAX_CHARS = 5000;
-const CACHE_MAX = 20000;
-const BUSY_MAX = 4; // LibreTranslate is CPU-bound: a few at once, the rest wait their turn
-const cache = new Map(); // sha256(target|text) -> { text, source }
-let busy = 0;
-const waiting = [];
-let langs = null, langsAt = 0;
-
-async function slot() {
- if (busy < BUSY_MAX) { busy++; return; }
- await new Promise((r) => waiting.push(r));
- busy++;
-}
-function release() { busy--; const next = waiting.shift(); if (next) next(); }
-
-// The languages the container has models for: { code: [targets] }
-export async function languages() {
- if (langs && Date.now() - langsAt < 3600_000) return langs;
- const r = await fetch(LT + '/languages', { signal: AbortSignal.timeout(5000) });
- if (!r.ok) throw new Error('languages ' + r.status);
- const list = await r.json();
- langs = Object.fromEntries(list.map((l) => [l.code, l.targets || []]));
- if (langs['zh-Hans']) langs.zh = langs['zh-Hans']; // the app says 'zh'
- langsAt = Date.now();
- return langs;
-}
-
-// The app's locale codes ↔ LibreTranslate's ('zh' is Simplified there).
-const norm = (c) => String(c || '').toLowerCase().split(/[-_]/)[0];
-const toLt = (c) => (c === 'zh' ? 'zh-Hans' : c);
-
-export async function translate(text, target, source = 'auto') {
- text = String(text || '');
- target = norm(target);
- source = source && source !== 'auto' ? norm(source) : 'auto';
- if (!text.trim()) return { text: '', source };
- if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
- const have = await languages();
- if (!have[toLt(target)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
- const key = createHash('sha256').update(target + '|' + source + '|' + text).digest('hex');
- const hit = cache.get(key);
- if (hit) { cache.delete(key); cache.set(key, hit); return hit; }
- const { prose, tags } = translatable(text, target);
- if (!prose.length && !tags.length) return { text, source: source === 'auto' ? null : source, nothing: true };
- await slot();
- try {
- const call = async (path, body) => {
- const r = await fetch(LT + path, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
- });
- const out = await r.json().catch(() => ({}));
- if (!r.ok) throw Object.assign(new Error(out.error || path + ' ' + r.status), { status: r.status === 400 ? 422 : 502 });
- return out;
- };
- let from = source, confidence = null;
- if (from === 'auto') {
- const d = await call('/detect', { q: [...prose.map((p) => p.prose), ...tags.map((p) => p.keep.slice(1).replace(/_/g, ' '))].join('\n') });
- from = norm(d[0]?.language) || null;
- confidence = d[0]?.confidence ?? null;
- if (!from) throw Object.assign(new Error('unknown language'), { status: 422 });
- }
- if (from === target) return { text, source: from, confidence };
- if (!have[toLt(from)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
- const translated = await translatePieces(text, async (qs) => {
- const r = await call('/translate', { q: qs, source: toLt(from), target: toLt(target), format: 'text' });
- return Array.isArray(r.translatedText) ? r.translatedText : [r.translatedText];
- }, target);
- const out = { text: translated, source: from, confidence };
- cache.set(key, out);
- if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
- return out;
- } finally { release(); }
-}
-
-// DeepL, with the reader's own API key (sent per request; never stored, and
-// not part of the cache key beyond the provider). DeepL refuses browsers
-// (no CORS), which is the only reason this passes through here. A free key
-// ends in ":fx" and has its own host.
-const DEEPL_TARGET = { en: 'EN-US', pt: 'PT-BR', zh: 'ZH-HANS' };
-export async function translateDeepl(text, target, key, fetchImpl = fetch) {
- text = String(text || '');
- target = norm(target);
- if (!text.trim()) return { text: '', source: null };
- if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
- if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(key)) throw Object.assign(new Error('bad DeepL key'), { status: 401 });
- const ck = createHash('sha256').update('deepl|' + target + '|' + text).digest('hex');
- const hit = cache.get(ck);
- if (hit) { cache.delete(ck); cache.set(ck, hit); return hit; }
- const { prose, tags } = translatable(text, target);
- if (!prose.length && !tags.length) return { text, source: null, nothing: true };
- const host = /:fx$/i.test(key) ? 'https://api-free.deepl.com' : 'https://api.deepl.com';
- let from = null;
- const translated = await translatePieces(text, async (qs) => {
- const r = await fetchImpl(host + '/v2/translate', {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: 'DeepL-Auth-Key ' + key },
- body: JSON.stringify({ text: qs, target_lang: DEEPL_TARGET[target] || target.toUpperCase() }),
- signal: AbortSignal.timeout(20_000),
- });
- if (r.status === 403 || r.status === 401) throw Object.assign(new Error('DeepL refused the key'), { status: 401 });
- if (r.status === 456) throw Object.assign(new Error('DeepL quota exceeded'), { status: 402 });
- if (r.status === 400) throw Object.assign(new Error('DeepL can’t translate into that language'), { status: 422 });
- if (!r.ok) throw Object.assign(new Error('DeepL ' + r.status), { status: 502 });
- const j = await r.json();
- const out = (j.translations || []).map((x) => x.text);
- from ||= norm(j.translations?.[0]?.detected_source_language) || null;
- if (out.length !== qs.length) throw Object.assign(new Error('DeepL answered short'), { status: 502 });
- return out;
- }, target);
- const out = { text: from === target ? text : translated, source: from };
- cache.set(ck, out);
- if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
- return out;
-}
diff --git a/nwcpush/.gitignore b/nwcpush/.gitignore
deleted file mode 100644
index 45bbf0e..0000000
--- a/nwcpush/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/
-config.json
diff --git a/nwcpush/config.example.json b/nwcpush/config.example.json
deleted file mode 100644
index 6b1abd8..0000000
--- a/nwcpush/config.example.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "port": 8797,
- "relays": ["wss://relay.coinos.io", "wss://relay.damus.io", "wss://nos.lol"],
- "vapid": { "subject": "mailto:[email protected]", "publicKey": "REPLACE", "privateKey": "REPLACE" },
- "stateFile": "./data/registrations.json",
- "maxPubkeysPerDevice": 20,
- "staleDays": 30
-}
diff --git a/nwcpush/server.js b/nwcpush/server.js
deleted file mode 100644
index 6d56f84..0000000
--- a/nwcpush/server.js
+++ /dev/null
@@ -1,529 +0,0 @@
-// NWC push notifier — step 1 of making hal answer wallet-connect requests
-// while it is closed.
-//
-// hal is a web wallet: a service worker cannot hold a relay socket open, so
-// something has to tell the browser "a request arrived, wake up". That is all
-// this does. It watches the relays for NIP-47 requests (kind 23194) addressed
-// to the wallet-service pubkeys a device has registered, and sends that device
-// a Web Push. The service worker then does the actual work — fetch, decrypt,
-// pay, reply — in the browser, with the keys never leaving it.
-//
-// WHAT THIS SERVICE LEARNS: the wallet-service pubkeys you register, and the
-// timing of requests to them. That is a real metadata leak and is the price of
-// push. It never sees request contents (encrypted to keys it does not have),
-// never sees your wallet seed, and can never move funds. The worst it can do
-// is fail to wake you, or lie about a wake-up — both of which cost nothing.
-//
-// Deliberately dumb: no database, no accounts. Registrations live in one JSON
-// file and expire if a device stops refreshing them.
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { SimplePool } from 'nostr-tools/pool';
-import { verifyEvent } from 'nostr-tools/pure';
-import webpush from 'web-push';
-
-// crude per-IP publish limiter: 20/min is far above a wallet's real reply rate
-const pubRate = new Map();
-function rateOk(ip) {
- const now = Date.now();
- const arr = (pubRate.get(ip) || []).filter((t) => t > now - 60_000);
- if (arr.length >= 20) return false;
- arr.push(now);
- pubRate.set(ip, arr);
- if (pubRate.size > 500) for (const [k, v] of pubRate) { if (!v.some((t) => t > now - 60_000)) pubRate.delete(k); }
- return true;
-}
-
-const CFG = JSON.parse(readFileSync(process.env.NWCPUSH_CONFIG
- || join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, relays: [...], vapid: { publicKey, privateKey, subject },
-// stateFile, maxPubkeysPerDevice, staleDays }
-
-const RELAYS = CFG.relays || ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
-const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'registrations.json');
-const MAX_PK = CFG.maxPubkeysPerDevice || 20;
-const STALE_MS = (CFG.staleDays || 30) * 86400_000;
-const REQ_KIND = 23194;
-const OFFER_KIND = 21001;
-
-webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey);
-
-// Two ways to reach a device.
-//
-// Web Push is the browser's, and on Android it is Firebase's — a phone
-// without Google Play Services (GrapheneOS and friends) can't subscribe at
-// all. UnifiedPush is the other way: the user runs a distributor, the app
-// gets an endpoint, and anyone wanting to reach the device POSTs the payload
-// to it. No encryption envelope and no VAPID — the endpoint is the secret,
-// which is why we never log one in full.
-//
-// A subscription marked `unifiedpush` is the second kind; everything else is
-// a browser's. One function, so every send path serves both.
-const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string');
-async function deliver(sub, payload, opts = {}) {
- if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts);
- const res = await fetch(sub.endpoint, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: payload,
- signal: AbortSignal.timeout(8000),
- });
- if (!res.ok) {
- // shaped like web-push's errors so the callers' 404/410 pruning works
- const err = new Error(`unifiedpush ${res.status}`);
- err.statusCode = res.status;
- throw err;
- }
- return res;
-}
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// ---------------------------------------------------------------------------
-// registrations: { [servicePubkey]: [{ sub, id, updated }] }
-// ---------------------------------------------------------------------------
-
-let regs = {};
-// user-facing notifications: { [endpointId]: { sub, ptags: [], authors: [], updated } }
-// ptags → DMs / zap receipts p-tagged at the user; authors → community
-// stream pubkeys (kind 1059 wraps authored by a channel's derived key).
-let notifyRegs = {};
-(() => {
- try {
- const raw = JSON.parse(readFileSync(STATE, 'utf8'));
- if (raw && raw.nwc) { regs = raw.nwc; notifyRegs = raw.notify || {}; }
- else regs = raw || {}; // pre-notify state file
- } catch {}
-})();
-function persist() {
- mkdirSync(dirname(STATE), { recursive: true });
- const tmp = STATE + '.tmp';
- writeFileSync(tmp, JSON.stringify({ nwc: regs, notify: notifyRegs }));
- renameSync(tmp, STATE);
-}
-// A device that stops refreshing drops off, so a stale endpoint can't keep us
-// subscribed to a pubkey forever.
-function prune() {
- const cutoff = Date.now() - STALE_MS;
- let dropped = 0;
- for (const pk of Object.keys(regs)) {
- regs[pk] = (regs[pk] || []).filter((r) => (r.updated || 0) > cutoff);
- if (!regs[pk].length) { delete regs[pk]; dropped++; }
- }
- for (const id of Object.keys(notifyRegs)) {
- if ((notifyRegs[id].updated || 0) <= cutoff) { delete notifyRegs[id]; dropped++; }
- }
- if (dropped) { persist(); log(`pruned ${dropped} stale registration(s)`); resubscribe(); }
-}
-
-const watchedPubkeys = () => Object.keys(regs);
-
-// ---------------------------------------------------------------------------
-// relay watcher
-// ---------------------------------------------------------------------------
-
-const pool = new SimplePool();
-let sub = null;
-let lastResub = 0;
-
-const notifyPtags = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.ptags || []))];
-const notifyAuthors = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.authors || []))];
-
-let notifySub = null;
-function resubscribeNotify() {
- try { notifySub?.close(); } catch {}
- notifySub = null;
- const ptags = notifyPtags();
- const authors = notifyAuthors();
- if (!ptags.length && !authors.length) return;
- const filters = [];
- // DMs / direct invites / zap receipts land p-tagged at the user
- // ...as do replies and mentions (kind 1 naming the user) and reactions (7)
- if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#p': ptags, since: Math.floor(Date.now() / 1000) });
- // community chat: wraps authored by a channel's derived stream key
- if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) });
- // one sub per filter — the pool API takes a single filter object
- notifySub = filters.map((f) => pool.subscribeMany(RELAYS, f, { onevent: onNotifyEvent }));
- notifySub.close = function () { for (const x of this) { try { x.close(); } catch {} } };
- log(`notify: watching ${ptags.length} ptag(s), ${authors.length} stream author(s)`);
-}
-
-// throttle: chat bursts collapse to one push per device per window
-// DMs are throttled barely at all now: the device drops the ones it decides
-// are noise (a stranger, or our own sent-copy), and a server-side cooldown
-// would let a discarded push swallow the friend's message that followed it.
-const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000 };
-const lastPush = new Map(); // endpointId:reason -> ts
-const seenNotifyEvents = new Map(); // event id -> ts
-function onNotifyEvent(ev) {
- if (seenNotifyEvents.has(ev.id)) return;
- seenNotifyEvents.set(ev.id, Date.now());
- if (seenNotifyEvents.size > 4000) {
- const cut = Date.now() - 600_000;
- for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k);
- }
- const p = ev.tags?.find((t) => t[0] === 'p')?.[1];
- // A public reply or mention: the note is readable here, so the device
- // gets what it needs to say who and what — an excerpt, and whether it
- // answers a post (e tag) or just names the person.
- if (ev.kind === 1) {
- const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
- const reply = (ev.tags || []).some((t) => t[0] === 'e' && t[1]);
- const extra = { from: ev.pubkey, reply, text: String(ev.content || '').replace(/\s+/g, ' ').slice(0, 140) };
- for (const [id, r] of Object.entries(notifyRegs)) {
- const mine = r.ptags || [];
- if (mine.includes(ev.pubkey)) continue; // their own post, not news
- if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'mention', extra).catch(() => {});
- }
- return;
- }
- // A like or emoji on the user's post. Opt-in by an explicit flag: an app
- // too old to send one has no words for it and would call it chat.
- if (ev.kind === 7) {
- const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
- const extra = { from: ev.pubkey, emoji: String(ev.content || '+').slice(0, 16) };
- for (const [id, r] of Object.entries(notifyRegs)) {
- const mine = r.ptags || [];
- if (r.reasons?.reaction !== true || mine.includes(ev.pubkey)) continue;
- if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'reaction', extra).catch(() => {});
- }
- return;
- }
- const zap = ev.kind === 9735 || ev.kind === 9737;
- const reason = zap ? 'payment'
- : p && notifyPtags().includes(p) ? 'dm' : 'chat';
- // A wrapped DM hides its sender from us by design, so we can't decide here
- // whether it's from a friend or a stranger — we hand the whole wrap to the
- // device, which holds the only key that can open it. Web Push tops out
- // around 4KB after encryption; anything larger goes as a bare nudge and the
- // device shows its generic notification.
- const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {};
- // a coinos zap receipt says how much in the clear
- const amt = ev.kind === 9737 ? parseInt(ev.tags?.find((t) => t[0] === 'amount')?.[1], 10) : 0;
- if (amt > 0) extra.amountSat = amt;
- for (const [id, r] of Object.entries(notifyRegs)) {
- const hit = reason === 'chat'
- ? (r.authors || []).includes(ev.pubkey)
- : p && (r.ptags || []).includes(p);
- // a zap is its own category for apps that know one; older apps keep
- // getting it as a payment
- if (hit) pushNotify(id, r, zap && typeof r.reasons?.zap === 'boolean' ? 'zap' : reason, extra).catch(() => {});
- }
-}
-
-async function pushNotify(id, r, reason, extra) {
- if (r.reasons && r.reasons[reason] === false) return; // opted out of this category
- const key = id + ':' + reason;
- const last = lastPush.get(key) || 0;
- if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return;
- lastPush.set(key, Date.now());
- const payload = JSON.stringify({ type: 'notify', reason, ...extra });
- // Android holds a 'normal' push while the phone dozes and batches it into
- // the next maintenance window — and drops it once TTL runs out first. A
- // phone in a pocket missed most zaps and mentions this way (a laptop never
- // dozes, so it got them all). Money and people talking to you go 'high'
- // and keep for a day; reactions and community chatter can wait.
- const loud = ['payment', 'zap', 'dm', 'mention'].includes(reason);
- const opts = loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' };
- try {
- try {
- await deliver(r.sub, payload, opts);
- } catch (e) {
- // A payload the push service won't take (413, or the library refusing
- // the size) must not cost the notification itself — send the bare nudge
- // and let the device show its generic message.
- if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e;
- log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`);
- await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), opts);
- }
- log(`notify(${reason}) -> device ${id}`);
- } catch (e) {
- // 404/410 = gone; 403 = the sub was made against a different VAPID key
- // and can NEVER receive a push (a stale key rotation), so prune it too —
- // the client re-subscribes fresh against the current key on next open.
- if (e.statusCode === 404 || e.statusCode === 410 || e.statusCode === 403) {
- delete notifyRegs[id];
- persist();
- resubscribeNotify();
- log(`dropped a dead notify endpoint ${id} (${e.statusCode})`);
- } else {
- log(`notify(${reason}) push failed for ${id}: ${e.statusCode || e.message}`);
- }
- }
-}
-
-function resubscribe() {
- resubscribeNotify();
- const pks = watchedPubkeys();
- try { sub?.close(); } catch {}
- sub = null;
- if (!pks.length) { log('nothing registered for nwc; not subscribed'); return; }
- lastResub = Date.now();
- sub = pool.subscribeMany(
- RELAYS,
- // only live traffic: a replayed old request must not wake devices.
- // NOTE: nostr-tools ≥2.23 wants a single filter object, NOT an array —
- // an array serializes as an invalid REQ that relays reject with CLOSED.
- { kinds: [REQ_KIND, OFFER_KIND], '#p': pks, since: Math.floor(Date.now() / 1000) },
- {
- onevent: (ev) => {
- const target = ev.tags?.find((t) => t[0] === 'p')?.[1];
- if (target) { noteRequest(ev.id); wake(target, ev.id, ev).catch(() => {}); }
- },
- },
- );
- // Track replies for the requests we forwarded, so an auto-answering worker
- // can ask "was this already answered by an open device elsewhere?" before
- // it pays. Only e-tags of requests we have seen are recorded.
- try { answeredSub?.close(); } catch {}
- answeredSub = pool.subscribeMany(
- RELAYS,
- { kinds: [RES_KIND, OFFER_KIND], since: Math.floor(Date.now() / 1000) },
- {
- onevent: (ev) => {
- for (const t of ev.tags || []) {
- if (t[0] === 'e' && recentReqs.has(t[1])) answeredIds.set(t[1], Date.now());
- }
- },
- },
- );
- log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`);
-}
-
-// requests we pushed recently, and which of them got a reply
-const RES_KIND = 23195;
-let answeredSub = null;
-const recentReqs = new Map(); // request event id -> ts
-const answeredIds = new Map(); // request event id -> ts a 23195 e-tagged it
-// First come, first pays: request event id -> { by, ts }. Every awake device
-// of a wallet sees every pay request, and two spending at once each lock an
-// HTLC for the same invoice — the loser's refusal then reached the client as
-// a failure. A device claims a request before spending; a claim held by
-// someone else means stand down. Memory only, minutes of life, no keys.
-const claims = new Map();
-function noteRequest(id) {
- recentReqs.set(id, Date.now());
- if (recentReqs.size > 2000) pruneRequests();
-}
-function pruneRequests() {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k);
- for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k);
- for (const [k, c] of claims) if (c.ts < cut) claims.delete(k);
-}
-
-// De-dupe: the same event arrives from several relays.
-const recentlyWoken = new Map();
-async function wake(servicePk, eventId, ev) {
- const key = servicePk + ':' + eventId;
- if (recentlyWoken.has(key)) return;
- recentlyWoken.set(key, Date.now());
- if (recentlyWoken.size > 2000) {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentlyWoken) if (t < cut) recentlyWoken.delete(k);
- }
-
- const targets = regs[servicePk] || [];
- if (!targets.length) return;
- // The payload carries the (still encrypted, still client-signed) request
- // event so an auto-answering service worker can act on it without holding
- // a relay socket. Web Push payloads cap around 4KB — an oversized event is
- // omitted and the worker falls back to waking the user.
- const clean = ev && {
- id: ev.id, pubkey: ev.pubkey, created_at: ev.created_at,
- kind: ev.kind, tags: ev.tags, content: ev.content, sig: ev.sig,
- };
- let payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk, event: clean });
- if (payload.length > 3800) payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk });
- let ok = 0;
- for (const r of targets) {
- try {
- await deliver(r.sub, payload, { TTL: 60, urgency: 'high' });
- ok++;
- } catch (e) {
- // 404/410 mean the browser dropped the subscription: forget it.
- if (e.statusCode === 404 || e.statusCode === 410) {
- regs[servicePk] = (regs[servicePk] || []).filter((x) => x.id !== r.id);
- if (!regs[servicePk].length) delete regs[servicePk];
- persist();
- log(`dropped an expired push endpoint for ${servicePk.slice(0, 12)}`);
- } else {
- log('push failed:', e.statusCode || e.message);
- }
- }
- }
- log(`woke ${ok}/${targets.length} device(s) for ${servicePk.slice(0, 12)}`);
-}
-
-// ---------------------------------------------------------------------------
-// HTTP
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type',
- 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
- },
-});
-
-const server = Bun.serve({
- port: CFG.port || 8797,
- async fetch(req) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
-
- if (url.pathname === '/health') {
- return json({
- ok: true, relays: RELAYS, watching: watchedPubkeys().length,
- subscribed: !!sub, lastResub,
- });
- }
-
- // The browser needs this to create a PushSubscription.
- if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey });
-
- // Publish-back for the auto-answering service worker: it cannot hold a
- // relay socket, so it hands us its (encrypted, service-key-signed) reply.
- // Strictly kind 23195, size-capped, signature-verified, rate-limited —
- // this is a reply pipe, not an open relay proxy.
- if (url.pathname === '/publish' && req.method === 'POST') {
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const ev = body?.event;
- if (!ev || ![RES_KIND, OFFER_KIND].includes(ev.kind)) return json({ error: 'kind 23195/21001 events only' }, 400);
- if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400);
- if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400);
- const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1];
- try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {}
- if (eTag) answeredIds.set(eTag, Date.now());
- log(`published a reply for request ${(eTag || '?').slice(0, 12)}`);
- return json({ ok: true });
- }
-
- // Server-to-server: the names registrar reports a settled receive for a
- // pubkey. Token-gated; pushes 'payment received' to that user's devices.
- if (url.pathname === '/notify' && req.method === 'POST') {
- const body = await req.json().catch(() => null);
- if (!CFG.notifyToken || body?.token !== CFG.notifyToken) return json({ error: 'forbidden' }, 403);
- const pk = body?.pubkey;
- if (!/^[0-9a-f]{64}$/.test(pk || '')) return json({ error: 'pubkey required' }, 400);
- // Network scoping: the same pubkey exists on mainnet and staging, so a
- // payment goes only to devices on its own network. A device that hasn't
- // re-registered since this shipped has net=null and still gets it (so
- // no one goes dark mid-rollout); once both sides send a net, mismatches
- // are filtered. A caller that omits net pushes to everyone (legacy).
- const wantNet = typeof body.net === 'string' ? body.net : null;
- let n = 0;
- for (const [id, r] of Object.entries(notifyRegs)) {
- if (!(r.ptags || []).includes(pk)) continue;
- if (wantNet && r.net && r.net !== wantNet) continue;
- pushNotify(id, r, 'payment', { amountSat: Number(body.amountSat) || undefined }).catch(() => {});
- n++;
- }
- return json({ ok: true, devices: n });
- }
-
- // Claim a pay request before spending on it. The first claimant (or the
- // same one again) gets ok:true; anyone else ok:false and stands down.
- if (url.pathname === '/claim' && req.method === 'POST') {
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const id = body?.event, by = String(body?.by || '').slice(0, 64);
- if (!/^[0-9a-f]{64}$/.test(id || '') || !by) return json({ error: 'event and by required' }, 400);
- if (claims.size > 5000) pruneRequests();
- const cur = claims.get(id);
- if (cur && cur.by !== by && Date.now() - cur.ts < 300_000) return json({ ok: false, by: cur.by });
- claims.set(id, { by, ts: Date.now() });
- return json({ ok: true });
- }
-
- // Was a request we pushed already answered by someone? Lets a worker
- // avoid double-paying (or shouting an error over) another device's reply.
- if (url.pathname === '/answered') {
- const id = url.searchParams.get('event') || '';
- return json({ answered: answeredIds.has(id) });
- }
-
- // A device registers its push endpoint plus the wallet-service pubkeys it
- // wants woken for. Re-post periodically to stay alive.
- if (url.pathname === '/register' && req.method === 'POST') {
- const body = await req.json().catch(() => null);
- const sub_ = body?.subscription;
- const hasNwc = Array.isArray(body?.servicePubkeys);
- const pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p));
- // user-facing notification watch (messages + payments) — independent of
- // the NWC registration so either can refresh without clobbering the other
- if (body?.notify && sub_?.endpoint) {
- const hex = (a, cap) => [...new Set((a || []).filter((p) => /^[0-9a-f]{64}$/.test(p)))].slice(0, cap);
- const id = Bun.hash(sub_.endpoint).toString(36);
- // per-category opt-outs; absent means everything on (older clients)
- const reasons = {};
- for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction']) {
- if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k];
- }
- notifyRegs[id] = {
- sub: sub_, updated: Date.now(),
- ptags: hex(body.notify.ptags, 8),
- authors: hex(body.notify.authors, 64),
- reasons,
- // the network this device is on: the same seed yields the same
- // pubkey on mainnet and staging, so a payment push is delivered
- // only to devices on the payment's network (see /notify)
- net: typeof body.notify.net === 'string' ? body.notify.net.slice(0, 16) : null,
- };
- persist();
- resubscribeNotify();
- if (!hasNwc) return json({ ok: true, notify: true });
- }
- if (!sub_?.endpoint || !pks.length) return json({ error: 'subscription and servicePubkeys required' }, 400);
- if (pks.length > MAX_PK) return json({ error: `at most ${MAX_PK} pubkeys` }, 400);
- // one id per endpoint so re-registering replaces rather than duplicates
- const id = Bun.hash(sub_.endpoint).toString(36);
- const known = new Set(pks);
- for (const pk of pks) {
- regs[pk] = (regs[pk] || []).filter((r) => r.id !== id);
- regs[pk].push({ sub: sub_, id, updated: Date.now() });
- }
- // drop this device from pubkeys it no longer cares about
- for (const pk of Object.keys(regs)) {
- if (known.has(pk)) continue;
- const before = regs[pk].length;
- regs[pk] = regs[pk].filter((r) => r.id !== id);
- if (!regs[pk].length) delete regs[pk];
- if (before !== (regs[pk]?.length ?? 0)) { /* changed */ }
- }
- persist();
- resubscribe();
- return json({ ok: true, watching: pks.length });
- }
-
- if (url.pathname === '/register' && req.method === 'DELETE') {
- const body = await req.json().catch(() => null);
- if (!body?.endpoint) return json({ error: 'endpoint required' }, 400);
- const id = Bun.hash(body.endpoint).toString(36);
- for (const pk of Object.keys(regs)) {
- regs[pk] = regs[pk].filter((r) => r.id !== id);
- if (!regs[pk].length) delete regs[pk];
- }
- delete notifyRegs[id];
- persist();
- resubscribe();
- return json({ ok: true });
- }
-
- return json({ error: 'not found' }, 404);
- },
-});
-
-log(`nwc push notifier on :${server.port}`);
-log(` relays: ${RELAYS.join(' ')}`);
-log(` watching ${watchedPubkeys().length} service pubkey(s)`);
-resubscribe();
-setInterval(prune, 3600_000);
-// relays drop long-lived subs; re-arm periodically
-setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);
diff --git a/package.json b/package.json
index f728400..d54fead 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@scure/bip32": "^1.7.0",
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
+ "hls.js": "1.6.13",
"jsqr": "^1.4.0",
"mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
diff --git a/src/api.js b/src/api.js
index c0ecb4b..7f22e78 100644
--- a/src/api.js
+++ b/src/api.js
@@ -232,7 +232,7 @@ export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deri
const ARK_PRESETS_BY_NET = {
mainnet: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
- { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api' },
+ { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api', honorsExpired: true },
{ id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
@@ -249,12 +249,12 @@ const ARK_PRESETS_BY_NET = {
],
mutinynet: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
- { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora' },
+ { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora', honorsExpired: true },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
regtest: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
- { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002' },
+ { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002', honorsExpired: true },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
};
@@ -294,7 +294,10 @@ export function getArkConfig(net = getNetwork()) {
const id = getArkProviderId(net);
if (id === 'off') return null;
const p = arkPresets(net).find((x) => x.id === id);
- const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora };
+ // honorsExpired: the coinos servers keep cosigning spends of a coin past
+ // its timelock (captaind allow_expired_arkoor) — the basis of the default
+ // "coinos holds my coins" custody tier. Other servers refuse such a coin.
+ const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora, honorsExpired: !!p.honorsExpired };
return cfg.ark && cfg.esplora ? cfg : null;
}
diff --git a/src/app.js b/src/app.js
index 7f6baa3..69c6275 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,4 @@
+import { customMediaServers, saveMediaServers, resetMediaServers } from './media-servers.js';
// Bitcoin Wallet — UI controller (vanilla DOM, no framework).
//
// State lives in `ui` + the singleton `wallet`. Mutating handlers call render(),
@@ -11,7 +12,7 @@ import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './rec
import { assetUrl } from './asset-url.js';
import { npubOf, seedPubkey, neventOf, parseNostrPubkey } from './nostr.js';
import { nip98Header } from './nip98.js';
-import { uploadPublicMedia, MediaUploadError } from './media-upload.js';
+import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
@@ -584,10 +585,27 @@ let _publicChatMode = false;
// welcome screen is the one screen that must never be guessed at. The boot
// shell holds the frame for those few ms instead; see the dispatch below.
let _bootDeciding = true;
+// True until boot's restore has run. The feed is a public page: opened by
+// URL it paints before the wallet restores, and it painted as a visitor's
+// (no post button, chips or nav) until the restore landed a beat later —
+// synchronous work (the vault's scrypt, the wallet's state, every feature's
+// init), ~0.5 s on a laptop and more on a phone (Adam, 2026-10-31). When
+// boot can already tell a wallet is about to open (restorePending), the
+// page paints as that wallet's from its first frame.
+let _restorePending = true;
+// Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page
+// starts): the app notes the phase it reaches, and clears the count once it
+// has lived eight seconds or leaves cleanly. A page iOS kills while opening
+// does neither, so the third start in a row opens in safe mode.
+function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }
+function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} }
+const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot);
+document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); });
+addEventListener('pagehide', bootSettled);
// Belt and braces: a boot path that returns early (or throws) must not leave
// the welcome screen held back forever. Nothing legitimate is still deciding
// four seconds in — the store wait alone caps at 800ms.
-setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000);
+setTimeout(() => { _restorePending = false; if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000);
// A picture, full screen. Posts show images cropped to a few hundred pixels
// so a feed stays a feed; this is where you actually look at one. Tap
// anywhere (or Escape, or Back) to close — no chrome, nothing to aim at.
@@ -849,11 +867,15 @@ function renderInner() {
// The bottom nav rides every page of a signed-in wallet and a visitor's
// public pages — never over sign-in, onboarding or the lock prompt.
const navPage = ui.pubProf || (activeAccount() && ['wallet', 'accounts', 'accountSettings'].includes(ui.screen) && !(ui.onb || onbInProgress()));
- const bottomNav = navPage && !lockAsk && !_bootDeciding ? featureHook('bottomNav') : null;
+ // (a wallet about to restore gets its nav with the page, not after)
+ const bottomNav = navPage && !lockAsk && (!_bootDeciding || restorePending()) ? featureHook('bottomNav') : null;
if (bottomNav) bottomNav.prepend(h('div', { class: 'app-nav-wide app-nav-brand' }, brandLogo()));
- // the wallet beside the feed and chats on a wide screen (its own page needs no twin)
- const side = bottomNav && featScreen && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null;
- morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : [])]);
+ // the wallet beside the feed and chats on a wide screen (its own page needs
+ // no twin; a wallet still restoring has no numbers to show yet)
+ const side = bottomNav && featScreen && !restorePending() && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null;
+ // a call, over whatever page is up (the messages feature's)
+ const callOv = activeAccount() ? featureHook('callOverlay') : null;
+ morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {}
if (fpath) {
@@ -909,7 +931,7 @@ wallet.subscribe(scheduleRender);
// lightbox: a full-screen photo is a place too — the phone's Back must close
// it, not pop the screen under it (which once walked a viewer straight back
// to the start page, so the photo's × then looked like a logout).
-const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
+const NAV_FIELDS = ['screen', 'tab', 'txDetail', 'arkMoveDetail', 'arkReconDetail', 'arkExitDetail', 'giftDetail', 'bump', 'giftMode', 'claimStep', 'chatOpen', 'msgView', 'feedId', 'pubProf', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'liveStream', 'goLive', 'userSearch', 'zapSetup', 'hatShop', 'lightbox', 'mediaPager', 'feedEdit', 'settingsView'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -917,6 +939,7 @@ function navSnapshot() {
// results streaming in) snapshot only what IDENTIFIES the screen — a
// keystroke must never mint a history entry.
if (ui.noteThread) s.noteThread = { rootId: ui.noteThread.rootId, focusId: ui.noteThread.focusId, seed: ui.noteThread.seed };
+ if (ui.liveStream) s.liveStream = { a: ui.liveStream.a, seed: ui.liveStream.seed };
if (ui.userSearch) s.userSearch = { q: '', rows: null };
if (ui.feedEdit) s.feedEdit = { ...ui.feedEdit, q: '', rows: null, pq: '', packRows: null, pt: null };
if (ui.zapSetup) s.zapSetup = { pk: ui.zapSetup.pk, npub: ui.zapSetup.npub, eventId: ui.zapSetup.eventId, amount: '21' };
@@ -939,7 +962,7 @@ let restoringHistory = false; // true while applying a popstate (suppresses push
// copy it, send it, reload it — the same paths the deep links already open.
// Every other screen keeps whatever path it had (the till at /pos, the
// plain app at /); only a path we set ourselves is taken back to /.
-const OWN_PATH = /^\/(nevent|note|npub)1[a-z0-9]+\/?$/i;
+const OWN_PATH = /^\/(nevent|note|npub|naddr)1[a-z0-9]+\/?$/i;
function navUrl(snap) {
try {
const fp = featureHook('navPath', snap); // a feed with a public address
@@ -1104,9 +1127,16 @@ function toast(msg, ms = 1600) {
document.body.append(t);
}
t.textContent = msg;
- requestAnimationFrame(() => t.classList.add('show'));
clearTimeout(toastTimer);
- toastTimer = setTimeout(() => t.classList.remove('show'), ms);
+ // The hide timer starts when the toast shows: a hidden tab pauses
+ // requestAnimationFrame but not setTimeout, so a toast raised in the
+ // background (a zap failing after you switched apps) used to be hidden
+ // first and shown on return, then stay up for good.
+ requestAnimationFrame(() => {
+ t.classList.add('show');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.classList.remove('show'), ms);
+ });
}
async function copy(text) {
@@ -1196,31 +1226,44 @@ let unit = (() => {
// been fetched — a third position showing blanks is worse than two that work.
function toggleUnit() {
const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc'];
+ const was = unit;
unit = ring[(ring.indexOf(unit) + 1) % ring.length];
try {
localStorage.setItem(UNIT_KEY, unit);
} catch {}
+ // An amount already typed into the send form is the same money in the new
+ // unit, not the same digits: 1000 sats does not become 1000 BTC. Whatever
+ // doesn't parse is left as typed.
+ for (const r of (ui.send && ui.send.recipients) || []) {
+ if (!r.amount || !String(r.amount).trim()) continue;
+ const sat = parseField(r.amount, was);
+ if (sat) r.amount = amountField(sat);
+ }
render();
}
// A sats figure as the amount field types it in the current unit — what a
// Max button or a fixed-amount invoice fills in. Fiat is floored to the
// cent so a filled-in maximum never parses back to more than was offered.
-// The figure it filled is remembered with its exact sats: a fiat maximum
+// The figures it filled are remembered with their exact sats: a fiat maximum
// rounded to the cent would otherwise parse back a few sats short and leave
-// them behind. Typing anything else parses as typed.
-let lastFill = null;
+// them behind. Typing anything else parses as typed. Several are kept so a
+// form with more than one amount round-trips each of them.
+const fills = new Map();
function amountField(sat) {
let s;
const r = unit === 'fiat' && rateNow();
if (unit === 'sats') s = String(sat);
else if (r) s = (Math.floor((sat / 1e8) * r * 100) / 100).toFixed(2);
else s = (sat / 1e8).toFixed(8);
- lastFill = { s, unit, sat };
+ fills.delete(unit + '|' + s);
+ fills.set(unit + '|' + s, sat);
+ while (fills.size > 16) fills.delete(fills.keys().next().value);
return s;
}
function parseField(v, u = unit) {
- if (lastFill && lastFill.unit === u && String(v).trim() === lastFill.s) return lastFill.sat;
+ const k = u + '|' + String(v).trim();
+ if (fills.has(k)) return fills.get(k);
return parseAmount(v, u, rateNow());
}
@@ -1920,7 +1963,7 @@ function identities() {
function clearFeatureNav() {
ui.arkCoinsPage = null; ui.arkExitPage = null;
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false;
- ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.zapSetup = null;
+ ui.chatOpen = false; ui.userSearch = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.zapSetup = null;
}
// Sign out of the CURRENT identity only. "Log out" locks every account on
@@ -2096,6 +2139,23 @@ function switchAccount(id) {
// Restore accounts after a refresh (sessionStorage); on a fresh session, prompt
// to unlock the encrypted vault if there is one, else seed from watch-only
// accounts. Returns true if it handled the entry (opened or showed a prompt).
+// Whether restoreAccountsState() is about to open a wallet, read from what
+// it reads but without its work: a session list in this tab, a vault the
+// last unlock opened without a password, or watch-only accounts. The vault
+// alone can't say whether a blank password opens it short of the scrypt
+// that is the cost being avoided, so the last seal or unlock leaves a note.
+const VAULT_BLANK_KEY = 'btc-wallet-vault-blank';
+const noteVaultBlank = (password) => { try { if (password === '') localStorage.setItem(VAULT_BLANK_KEY, '1'); else localStorage.removeItem(VAULT_BLANK_KEY); } catch {} };
+const vaultOpensBlank = () => { try { return localStorage.getItem(VAULT_BLANK_KEY) === '1'; } catch { return false; } };
+function bootWillRestore() {
+ if (!unlockDeadlinePassed()) {
+ try { const s = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); if (s && Array.isArray(s.accounts) && s.accounts.length) return true; } catch {}
+ }
+ if (hasVault()) return vaultOpensBlank();
+ return loadWatchAccounts().length > 0;
+}
+const restorePending = () => _restorePending && bootWillRestore();
+
function restoreAccountsState() {
let sess = null;
try { sess = JSON.parse(sessionStorage.getItem(ACCOUNTS_KEY) || 'null'); } catch {}
@@ -2147,6 +2207,7 @@ const passkeySecret = (pw) => typeof pw === 'string' && pw.startsWith('passkey:'
const vaultPasskey = () => (loadVaultBlob() || {}).passkey || null;
function sealVault(list, password) {
const b = encryptVault(list, password);
+ noteVaultBlank(password);
if (passkeySecret(password) && vaultPasskeyId) b.passkey = vaultPasskeyId;
return b;
}
@@ -2302,7 +2363,7 @@ function submitPw() {
let list;
try { list = decryptVault(loadVaultBlob(), p.v0 || ''); } catch { p.error = t('pwWrong'); render(); return; }
if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; }
- try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); } catch {}
+ try { localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, p.v1 || ''))); noteVaultBlank(p.v1 || ''); } catch {}
if (vaultPassword != null) vaultPassword = p.v1 || ''; // keep the unlocked session in sync
ui.pw = null;
render();
@@ -2443,6 +2504,7 @@ async function submitPwWithPasskey() {
function attemptVaultUnlock(password) {
let list;
try { list = decryptVault(loadVaultBlob(), password); } catch { return false; }
+ noteVaultBlank(password);
vaultPassword = password;
vaultPasskeyId = passkeySecret(password) ? vaultPasskey() : null;
// Start from the durable directory (watch-only views of every known wallet),
@@ -2970,7 +3032,7 @@ function settingsTab() {
switch (ui.settingsPage) {
// the recovery phrase first: it is the one thing on this page that
// matters if the phone is lost tonight
- case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a)] : []);
+ case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
@@ -3027,11 +3089,33 @@ function registerLinkHandler(scheme) {
toast(t('linkHandlerAsked'));
} catch (e) { toast(t('linkHandlerRefused')); }
}
+function mediaServersCard() {
+ if (ui.mediaServersDraft == null) ui.mediaServersDraft = (customMediaServers() || []).join('\n');
+ const save = (reset = false) => {
+ try {
+ if (reset) { resetMediaServers(); ui.mediaServersDraft = ''; }
+ else ui.mediaServersDraft = saveMediaServers(ui.mediaServersDraft).join('\n');
+ toast(t('mediaServersSaved'));
+ render();
+ } catch (e) { toast(t(e.message === 'invalid' ? 'mediaServersInvalid' : 'mediaServersSaveFailed')); }
+ };
+ return h('div', { class: 'card col', 'data-key': 'media-servers' },
+ h('h3', {}, t('mediaServersTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('mediaServersHow')),
+ h('textarea', { rows: 3, style: 'font-size:12px', 'aria-label': t('mediaServersTitle'),
+ placeholder: t('mediaServersDefault'), value: ui.mediaServersDraft, spellcheck: 'false',
+ onInput: (e) => { ui.mediaServersDraft = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost small', onClick: () => save() }, t('syncStorageSave')),
+ h('button', { class: 'btn-ghost small', onClick: () => save(true) }, t('syncStorageReset'))));
+}
+
function nostrSettingsView() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
...featureAll('nostrSettingsCards'),
+ mediaServersCard(),
// The browser's own "open these links here" registry: njump's "Your
// default web app" is a web+nostr: link, and it opens whichever site
// asked for that scheme last. Needs a tap (the browser asks).
@@ -3220,7 +3304,7 @@ function goHome() {
ui.profilePk = null;
ui.profEdit = null;
ui.userSearch = null;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.zapSetup = null;
ui.arkExitPage = null;
ui.settingsPage = null;
@@ -3605,6 +3689,46 @@ function pubkeyCard(a) {
);
}
+// Every payment in both balances as a CSV file, for a spreadsheet or an
+// accountant: Savings' on-chain transactions and Spending's movements (the
+// rows the features list in History, each with its `csv` line), oldest
+// first, priced in the chosen currency at the time each happened.
+function historyCsv() {
+ const cur = getCurrency();
+ const lines = [];
+ for (const tx of wallet.history || []) {
+ const out = tx.net < 0, fee = out ? tx.fee || 0 : 0;
+ lines.push({ ts: tx.confirmed && tx.blockTime ? tx.blockTime * 1000 : Date.now(), account: t('savingLabel'), type: out ? 'send' : 'receive', rail: 'onchain',
+ amount: out ? tx.net + fee : tx.net, fee, net: tx.net, status: tx.confirmed ? 'complete' : 'pending', ref: tx.txid, to: '', invoice: '', note: '' });
+ }
+ for (const [sel, account] of [['savings', t('savingLabel')], ['spending', t('spendingLabel')]])
+ for (const e of featureAll('historyEntries', wallet.history || [], sel)) if (e.csv) lines.push({ ...e.csv, account });
+ lines.sort((a, b) => a.ts - b.ts);
+ const q = (v) => (v == null ? '' : /[",\n\r]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v));
+ const head = ['Date (UTC)', 'Account', 'Type', 'Rail', 'Amount (sats)', 'Fee (sats)', 'Net (sats)', `Net (${cur} at the time)`, 'Status', 'Transaction / payment hash', 'To', 'Invoice', 'Note'];
+ const rows = lines.map((l) => {
+ const { rate, stale } = rateAt(l.ts / 1000);
+ const fiat = rate && !stale ? ((l.net / 1e8) * rate).toFixed(2) : '';
+ return [new Date(l.ts).toISOString().replace('T', ' ').slice(0, 19), l.account, l.type, l.rail, l.amount, l.fee || 0, l.net, fiat, l.status, l.ref, l.to, l.invoice, l.note];
+ });
+ return [head, ...rows].map((r) => r.map(q).join(',')).join('\r\n') + '\r\n';
+}
+function exportHistory() {
+ const name = `coinos-history-${new Date().toISOString().slice(0, 10)}.csv`;
+ const url = URL.createObjectURL(new Blob([historyCsv()], { type: 'text/csv;charset=utf-8' }));
+ const a = h('a', { href: url, download: name, style: 'display:none' });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+function historyExportCard() {
+ return h('div', { class: 'card col' },
+ h('h3', {}, t('historyExportTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('historyExportDesc')),
+ h('button', { class: 'btn-block history-export', disabled: !wallet.loaded, onClick: exportHistory }, t('historyExportBtn')));
+}
+
function autolockCard(a) {
return h('div', { class: 'card col' },
h('h3', {}, t('autolockTitle')),
@@ -3829,18 +3953,33 @@ function mediaProgress(stage, fraction) {
}
async function publicMediaUpload(file) {
try {
- // a phone video is shrunk on the device first (see video-compress.js)
+ const signEvent = await mediaUploadSigner(wallet, featureHook);
+ // a phone video is shrunk on the device first (see video-compress.js);
+ // a long one becomes an HLS stream, its master playlist the address
if (/^video\//.test(file?.type || '')) {
mediaProgress('shrink', 0);
- const { compressVideo } = await import('./video-compress.js');
+ const { compressVideo, streamVideo } = await import('./video-compress.js');
+ const stream = await streamVideo(file, (p) => mediaProgress('shrink', p));
+ if (stream) {
+ mediaProgress('upload', 0);
+ const url = await uploadStream(stream, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
+ return { url, m: 'application/vnd.apple.mpegurl', dim: stream.dim, duration: Math.round(stream.seconds) };
+ }
+ mediaProgress('shrink', 0);
file = await compressVideo(file, (p) => mediaProgress('shrink', p));
}
mediaProgress('upload', 0);
- return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ return await uploadPublicMedia(file, signEvent, { onProgress: (p) => mediaProgress('upload', p) });
} catch (error) {
+ if (error instanceof MediaUploadError && error.code === 'no-identity') throw new Error(t('msgNoIdentity'));
+ if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized'));
if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig'));
if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported'));
- throw new Error(t('msgUploadFailed'));
+ // what went wrong, said: "check your connection" alone hid a signer
+ // that never answered and hosts that refused the file
+ const why = String((error instanceof MediaUploadError ? error.details : '') || error?.message || '').slice(0, 200);
+ console.warn('media upload failed', error, why);
+ throw new Error(why && why !== 'failed' ? `${t('msgUploadFailed')} (${why})` : t('msgUploadFailed'));
} finally {
ui.mediaProgress = null;
}
@@ -4111,16 +4250,18 @@ function onboardScreen() {
}
if (o.step === 'spend') {
return page([
- title(t('onbSpendTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')),
+ title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')),
featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
// Optional means optional: one tap to the wallet, no questions — on
- // Savings, the account that exists without any setup.
+ // Savings, the account that exists without any setup (or, after a
+ // gift, the account the gift arrived in).
h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ const home = o.home || 'savings';
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
- ui.account = 'savings';
- try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
+ ui.account = home;
+ try { localStorage.setItem(ACCOUNT_KEY, home); } catch {}
ui.tab = 'receive';
render();
} }, t('onbNotNow')),
@@ -4340,6 +4481,15 @@ function claimMigratedName() {
attempt();
}
+// After a gift is claimed into a fresh wallet (and its seed written down):
+// the wizard's name → look → done steps, the rest of a first run. `home` is
+// the account a skip lands on — the one the gift arrived in.
+function offerProfileSetup({ home = 'savings' } = {}) {
+ if (!canOfferSpending()) return false;
+ ui.onb = { step: 'spend', enterAddr: undefined, fromGift: true, home };
+ return true;
+}
+
function leaveWizard() {
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
@@ -4401,6 +4551,7 @@ function walletScreen() {
brandHeader(true),
h('div', { class: 'mt16' }, balanceCard()),
ui.offlineFallback && wallet.offline ? offlineBanner() : null,
+ safeBootNotice(),
backupNotice(),
...featureAll('walletNotices'),
tabsBar(),
@@ -4459,6 +4610,24 @@ function backupNotice() {
} }, t('backupReminderGo')));
}
+// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
+// row, so this boot made no automatic connections. Say so, name the phase
+// the last boot reached, and offer the way back.
+function safeBootNotice() {
+ if (!safeBoot()) return null;
+ const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?';
+ const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'),
+ 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n');
+ return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' },
+ h('b', {}, t('safeBootTitle')),
+ h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })),
+ h('div', { class: 'row', style: 'gap:8px' },
+ h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')),
+ h('button', { class: 'btn-sm', onClick: (e) => {
+ navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {});
+ } }, t('safeBootCopy'))));
+}
+
function offlineBanner() {
return h(
'div',
@@ -6336,6 +6505,8 @@ const ctx = {
// support) — lazy so it resolves against the final FEATURES list, and a
// build without the target feature simply gets null back
hook: (name, ...args) => featureHook(name, ...args),
+ // boot is about to open a wallet (a page may paint as that wallet's already)
+ restorePending,
// full sign-out (distinct from the padlock's lock-in-place): clear the
// session and leave — offering a password if none protects the vault yet
logout: () => {
@@ -6362,6 +6533,7 @@ const ctx = {
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk || null,
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
+ offerProfileSetup, pickConfirm,
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
// Profile pictures retain the metadata-scrubbing image uploader. Public
@@ -6612,18 +6784,29 @@ if (INTENT_URI) {
// The feed scrolls like a phone on a desktop. A mouse wheel's notch gives
// it a push that coasts and slows, so a few turns travel a few posts and
// quick turns stack up; a trackpad (which already coasts) is left alone.
-// And a mouse can grab the feed and flick it: drag up or down, let go while
-// moving and it keeps going. A drag that starts sideways is still a text
-// selection, and a drag that moved isn't a click on what it started on.
+// (A mouse drag used to grab the feed and flick it; it selects text again —
+// Adam, 2026-10-18: "i'd rather be able to select text normally".)
(() => {
if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
- const SCOPE = '.feed-page, .notes-feed';
+ // The wheel coasts whenever a feed or a thread is the page being scrolled
+ // — wherever the pointer happens to be: over the posts, the header, the
+ // rail, the wallet column beside the feed on a wide screen (Adam,
+ // 2026-10-16: "when my cursor is hovered over the right side bar or any
+ // part of the page that's not the feed, wheeling scrolls without
+ // coasting. can we make it coast no matter where my pointer is?") —
+ // except over something that takes the wheel itself: a text box, the
+ // chips strip, the lightbox, the pager, a box that scrolls on its own.
+ const SCOPE = '.feed-page, .notes-feed, .thread-page'; // the pages that coast (a thread scrolls the same as a feed)
const TAU = 325; // ms: how quickly a coast dies down (the phone's feel)
const VMAX = 16; // px/ms
const inScope = (t) => {
const el = t && t.closest ? t : t && t.parentElement;
- if (!el || !el.closest(SCOPE)) return false;
- if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
+ if (!el || !document.querySelector(SCOPE)) return false;
+ // (A video or an audio player is part of the feed: a notch over a clip
+ // coasts like one over the words — the wheel does nothing to a player
+ // itself, so there is nothing to leave it. Adam, 2026-10-16: "over a
+ // video it doesn't initiate a coast, just a single notch".)
+ if (el.closest('input, textarea, select, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false;
// something inside that scrolls on its own (a caption, a list) keeps its wheel
for (let n = el; n && n !== document.body; n = n.parentElement) {
if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false;
@@ -6653,48 +6836,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,51 +6905,9 @@ 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 });
- // ---- grab and flick
- let drag = null;
- document.addEventListener('pointerdown', (e) => {
- if (raf) halt(); // a click catches a coasting page, like a finger does
- if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return;
- drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] };
- });
- document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out
- document.addEventListener('pointermove', (e) => {
- if (!drag) return;
- if (!(e.buttons & 1)) { drag = null; return; }
- if (drag.sel) return;
- if (!drag.on) {
- const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y);
- if (dx < 6 && dy < 6) return;
- // clearly sideways: selecting text (and still not a click on the post)
- if (dx > 2 * dy) { drag.sel = true; return; }
- drag.on = true;
- document.documentElement.classList.add('grab-scrolling');
- try { getSelection().removeAllRanges(); } catch {}
- }
- window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' });
- drag.ly = e.clientY;
- drag.pts.push([e.timeStamp, e.clientY]);
- while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift();
- });
- const end = (e) => {
- if (!drag) return;
- const d = drag; drag = null;
- if (!d.on && !d.sel) return;
- if (d.on) {
- document.documentElement.classList.remove('grab-scrolling');
- const p = d.pts, a = p[0], b = p[p.length - 1];
- // released while moving: throw it; held still first: just let go
- if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 2.4);
- }
- // the press moved: what it started on isn't opened (Chrome still clicks it)
- const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
- document.addEventListener('click', eat, { capture: true, once: true });
- setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 300);
- };
- document.addEventListener('pointerup', end);
- document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; });
+ window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); });
+ window.addEventListener('touchstart', stop, { passive: true });
+ document.addEventListener('pointerdown', () => { if (raf) stop(); }); // a click catches a coasting page, like a finger does
})();
window.addEventListener('load', () => {
@@ -6760,6 +6919,7 @@ window.addEventListener('load', () => {
}, 1000);
});
loadLocale(getLang()).finally(async () => {
+ bootPhase('boot');
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
// Public read-only community chat: no wallet, no login — the join material
// ships in the bundle, so the page decrypts client-side (src/public-chat.js).
@@ -6785,6 +6945,7 @@ loadLocale(getLang()).finally(async () => {
// and a wallet opening in fiat should open with a number in it.
refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {});
setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000);
+ bootPhase('store');
const storeReady = featureHook('arkStoreReady');
// the IndexedDB caches (profiles, reports, …) open alongside: a wallet's
// caches are read as soon as it opens
@@ -6794,7 +6955,10 @@ loadLocale(getLang()).finally(async () => {
try { navigator.storage?.persist?.().catch(() => {}); } catch {}
_bootDeciding = false; // from here on the screen is a decision, not a guess
const opened = restoreAccountsState();
- if (!opened) render();
+ _restorePending = false;
+ if (!opened || !activeAccount()) render();
+ bootPhase('ui');
+ setTimeout(bootSettled, 8000);
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is
// nothing to answer with, so say so now.
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 703d341..91b5f0a 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
// claim recorded before it stamped them — the same amount within minutes).
// Derived at read time, never deleted: a synced copy from another device
// can't bring a twin back.
+// Two receive rows that are one payment delivered as several coins (a gift
+// held in two swept at once, a dust-padded split): the same mailbox message
+// (`batch`), or for rows recorded before the stamp, the same delivery instant.
+// A receive the registrar's receipts tagged ⚡ is its own payment.
+export function sameDelivery(a, b) {
+ return !!a && !!b && a.type === 'receive' && b.type === 'receive'
+ && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn
+ && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts);
+}
+
export function lnFoldedIds(movements) {
const out = new Set();
const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId);
@@ -119,6 +129,24 @@ export function lnFoldedIds(movements) {
}
if (best) out.add(best.id);
}
+ // A Lightning payment the closed app's worker made is told twice once the
+ // app opens: the worker's own ln-send row (merged in from the mirror) and
+ // the "while closed" note the app adds from the worker's spend log. Per
+ // preimage (or payment hash), keep the row that names its inputs — the
+ // manager's — else the earliest.
+ const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash));
+ const keyOf = new Map(); // payment hash → the preimage key, where a row knows both
+ for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage);
+ const byPay = new Map();
+ for (const s of sends) {
+ const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash);
+ (byPay.get(k) || byPay.set(k, []).get(k)).push(s);
+ }
+ for (const rows of byPay.values()) {
+ if (rows.length < 2) continue;
+ const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b));
+ for (const r of rows) if (r !== keep) out.add(r.id);
+ }
return out;
}
@@ -171,9 +199,52 @@ 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);
+
+// Custody tiers — what a coin's timelock means to this wallet:
+// custodial nothing renews. The coinos server keeps honoring a coin past
+// its timelock (it has swept the tree; the coin is its IOU), so
+// the balance works like a v2 one, and no renewal is ever paid.
+// self renew in the last hours before expiry — the free ppm bracket,
+// one base fee per round — so a unilateral exit is always there.
+// exit the same, plus: renew a coin as soon as its history runs
+// `maxDepth` hops deep. A unilateral exit publishes one on-chain
+// transaction per hop, so depth — not age — is what makes an
+// exit expensive; a renewal flattens it to one hop. Early, so
+// the top ppm bracket + base; only for a coin worth it (the fee
+// under a tenth of its value — see maybeAutoRefresh).
+// A policy is { window, margin, grid } in blocks: the app renews a coin with
+// under `window` blocks left; the server-side backstop is registered `margin`
+// blocks before expiry, snapped DOWN to a multiple of `grid` so everyone's
+// orders land in the same round (one round's mining fee shared, not one per
+// coin). The snapped height stays inside the free bracket: 36–83 blocks out.
+export const CUSTODY_TIERS = ['custodial', 'self', 'exit'];
+export const MAX_SEND_INPUTS = 48;
+export function renewPolicy(tier, network) {
+ const rt = network === 'regtest';
+ const self = { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 };
+ // 'early' was the third tier for a day (2026-10-22): a week-ahead renewal,
+ // which flattens the same history a last-hours one does — mirrors may
+ // still carry the name
+ if (tier === 'exit' || tier === 'early') return { ...self, maxDepth: 4 };
+ if (tier === 'self') return self;
+ return null;
+}
+
export class ArkManager {
- constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) {
+ constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null, lnOldestFirst }) {
this.account = account; // HDKey node; ark keys derived beneath it
+ // The server's last published info, kept by the app: lets the wallet open
+ // when the server can't be reached (see init)
+ this.infoHint = infoHint || null;
+ this.offline = false;
+ // the server cosigns spends of a coin past its timelock (coinos): such a
+ // coin stays money here — spendable, receivable — just not exitable
+ this.honorsExpired = !!honorsExpired;
this.storage = storage; // { load(): obj|null, save(obj): void }
this.arkUrl = arkUrl;
this.esploraUrl = esploraUrl;
@@ -187,6 +258,9 @@ export class ArkManager {
: network === 'mutinynet' ? 'https://names.halwallet.app/lnquote?network=mutinynet'
: null;
this.onUpdate = onUpdate || (() => {});
+ // () => true when this wallet renews its own coins (custody tier self or
+ // exit): a Lightning send then spends the coins nearest expiry first
+ this.lnOldestFirst = lnOldestFirst || (() => false);
this.state = null;
this.info = null;
this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push
@@ -236,7 +310,19 @@ export class ArkManager {
seen.add(m.vtxoId);
return true;
});
- this.info = await getArkInfo(this.arkUrl);
+ try {
+ this.info = await getArkInfo(this.arkUrl);
+ this.offline = false;
+ } catch (e) {
+ // The server is unreachable. With its last published info in hand the
+ // wallet still opens on its own records: coins read, a unilateral exit
+ // can start (that needs the chain, not the server), and anything that
+ // does need the server fails when it asks. Without that info there is
+ // no server key to check anything against — then it really is closed.
+ if (!this.infoHint) throw e;
+ this.info = this.infoHint;
+ this.offline = true;
+ }
const hs = await handshake(this.arkUrl).catch(() => null);
this.psa = hs?.psa;
this.serverPub = hex.decode(this.info.serverPubkey);
@@ -411,7 +497,9 @@ export class ArkManager {
// the exception — they have no unilateral exit to protect and the coinos ASP
// keeps honoring them off-chain (mirrors validateVtxo's carve-out).
_expired(v, tip) {
- return !!v.expiryRejected || (!!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip);
+ if (v.expiryRejected) return true; // the server said no to this one
+ if (this.honorsExpired) return false;
+ return !!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip;
}
// The dust exemption is not a promise that every old coin will be
@@ -456,6 +544,32 @@ export class ArkManager {
// free to spend, renew or exit from THIS device
_free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id) && this._policyType(v) === 'pubkey'; }
+ // A coin's exit depth: one transaction per hop of its history (the round
+ // tree's branch, then every arkoor transfer). Each off-chain payment adds
+ // one to the change and one to what the recipient gets; only a round
+ // flattens it. The server refuses to cosign a spend of a coin at its
+ // `maxVtxoExitDepth` (100 on coinos: a wallet paying hourly from one change
+ // chain gets there in four days), so such a coin is money only a refresh
+ // can free — and it refreshes without the base fee at `deepWaiverDepth`.
+ exitDepth(v) { try { return this._decoded(v).genesis.length; } catch { return 0; } }
+ _capDepth() { return (this.info && this.info.maxVtxoExitDepth) || 0; }
+ _waiverDepth() { return (this.info && this.info.refreshFees && this.info.refreshFees.deepWaiverDepth) || 0; }
+ _capped(v) { const cap = this._capDepth(); return !!cap && this.exitDepth(v) >= cap; }
+ // The free coins a refresh should flatten now: at the server's waiver depth
+ // (no base fee, so always worth it), or within a few hops of the cap on a
+ // server without one (stuck otherwise). Expired coins too: a round is the
+ // one thing an expired coin can always do, and the server honors it.
+ deepCoins() {
+ const waiver = this._waiverDepth(), cap = this._capDepth();
+ if (!waiver && !cap) return [];
+ const busy = this._remoteBusy();
+ return this.state.vtxos.filter((v) => {
+ if (!this._free(v, busy)) return false;
+ const d = this.exitDepth(v);
+ return (waiver && d >= waiver) || (cap && d >= cap - 8);
+ });
+ }
+
balance() {
const tip = this._tipH || 0;
const busy = this._remoteBusy();
@@ -502,6 +616,10 @@ export class ArkManager {
this._lnRescanned = true;
try { if (await this.rescanLnReceives()) await this._save(); } catch (e) { this._lnRescanned = false; }
}
+ if (!this._expiredRescanned) {
+ this._expiredRescanned = true;
+ try { if (await this.rescanRejectedExpired()) this._save(); } catch (e) { this._expiredRescanned = false; }
+ }
const baselining = !!this.state.baselinePending;
// keep the cached tip fresh: balance() and input selection judge coin
// expiry against it synchronously, and a stale tip hides dead coins
@@ -564,6 +682,59 @@ export class ArkManager {
}
}
+ // On a server that honors coins past their timelock, the ones this wallet
+ // wrote off before it knew that are still its money:
+ // - receives an older build refused at pickup ("vtxo expired at …"),
+ // recorded as rejected and left behind the mailbox cursor — the
+ // forwards stranded that way before 2026-08-24 among them;
+ // - coins the server once refused to spend (expiryRejected, from before
+ // allow_expired_arkoor), and expired dust the user gave up (Forget).
+ // Once per wallet: re-read the mailbox from the start and adopt what the
+ // server still reports spendable; clear the old verdicts the same way. The
+ // cursor is left where it is. Returns whether anything changed.
+ async rescanRejectedExpired() {
+ if (!this.honorsExpired || this.state.expiredRescanDone) return false;
+ let changed = false;
+ const alive = async (v) => {
+ try {
+ return (await getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey)) === VTXO_STATE_SPENDABLE;
+ } catch { return false; }
+ };
+ for (const v of this.state.vtxos) {
+ if (!(v.expiryRejected || (v.forgotten && v.state === 'spent'))) continue;
+ if (!(await alive(v))) continue;
+ delete v.expiryRejected; delete v.forgotten; v.state = 'spendable';
+ changed = true;
+ }
+ const rejected = (this.state.movements || []).filter((m) => m.type === 'receive' && m.status === 'rejected' && /expired at/i.test(m.detail || ''));
+ if (rejected.length) {
+ const mailbox = this._mailboxKey();
+ let checkpoint = 0;
+ for (let page = 0; page < 50; page++) {
+ const { messages, haveMore } = await readMailbox(this.arkUrl, mailbox, checkpoint);
+ for (const m of messages) {
+ checkpoint = Math.max(checkpoint, m.checkpoint || 0);
+ if (m.kind !== 'arkoor') continue;
+ const fresh = [];
+ for (const v of m.vtxos) {
+ if (this._vtxo(v.id)) continue;
+ // a coin another device of this wallet adopted and spent since
+ // is not money here; only what the server still holds for us is
+ let status = 0;
+ try { status = await getVtxoStatus(this.arkUrl, v.point.raw, this._key(0).privkey); } catch { status = 0; }
+ if (status === VTXO_STATE_SPENDABLE) fresh.push(v);
+ }
+ if (fresh.length && await this._handleMailboxMessage({ ...m, vtxos: fresh })) changed = true;
+ }
+ if (!haveMore || !messages.length) break;
+ }
+ this.state.movements = this.state.movements.filter((m) => !rejected.includes(m));
+ changed = true;
+ }
+ this.state.expiredRescanDone = true;
+ return true;
+ }
+
async _handleMailboxMessage(m) {
let changed = false;
if (m.kind === 'lnSendFinished') {
@@ -634,7 +805,7 @@ export class ArkManager {
continue;
}
try {
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys });
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys, acceptExpired: this.honorsExpired });
} catch (e) {
if (e instanceof VtxoValidationError) {
this._movement({ type: 'receive', amountSat: v.amountSat, status: 'rejected', detail: e.message });
@@ -647,9 +818,13 @@ export class ArkManager {
// same message concurrently (validation awaits in between), and only the
// copy that actually added the vtxo may record the receive movement.
if (!this._addVtxo(v, v._raw.bytes, 0)) continue;
+ // `batch` names the mailbox message: one payment can arrive as several
+ // coins (a sweep of a gift held in two, a dust-padded split), and the
+ // history folds a message's coins back into one row
this._movement({
type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id,
ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)),
+ ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}),
});
changed = true;
}
@@ -727,8 +902,13 @@ export class ArkManager {
v.state = owner.step === 'done' || owner.step === 'failed' ? 'spent' : 'pending';
changed = true;
}
+ // An HTLC coin whose payment no action names any more (done ln-pays are
+ // pruned after a while) is an orphan too: five 22-sat ones sat 'pending'
+ // in a wallet for days, counted as money on its way. The server's answer
+ // settles it — spent is spent; one still open stays pending, since an
+ // HTLC coin is never this wallet's to spend (see below).
const candidates = this.state.vtxos.filter((v) => v.state === 'spendable'
- || (v.state === 'pending' && !held.has(v.id) && v.bytes && this._policyType(v) === 'pubkey'));
+ || (v.state === 'pending' && !held.has(v.id) && v.bytes));
const was = candidates.map((v) => v.state); // what each was when this pass began
// Each status query signs a challenge (schnorr) and may decode the vtxo —
// real secp256k1 work. Building them all in one synchronous map was a
@@ -753,8 +933,8 @@ export class ArkManager {
// an orphan the server still counts is simply spendable again; one it
// holds as spent is gone, and the pay that took it was recorded (or
// will merge in) elsewhere — no row of its own
- if (states[i] === VTXO_STATE_SPENDABLE) { v.state = 'spendable'; changed = true; }
- else if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ else if (states[i] === VTXO_STATE_SPENDABLE && this._policyType(v) === 'pubkey') { v.state = 'spendable'; changed = true; }
return;
}
if (states[i] !== VTXO_STATE_SPENT) return;
@@ -956,30 +1136,91 @@ export class ArkManager {
// that covers it, else largest-first until the sum does. A balance scattered
// by many small receives stays spendable — the package cosign spends all
// the inputs atomically and the recipient just receives several vtxos.
+ // A coin past its timelock spends LAST. The server honors it, and so does
+ // this build at pickup — but a receiver on an older build still refuses
+ // one as expired and strands it. Reaching for such a coin only when
+ // nothing else covers the amount narrows that to almost no payment.
+ _pastExpiry(v, tip) { return !!tip && !!v.expiryHeight && v.expiryHeight <= tip; }
+ _spendOrder(list, tip, cmp) {
+ const live = list.filter((v) => !this._pastExpiry(v, tip)).sort(cmp);
+ const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp);
+ return [...live, ...past];
+ }
+ // largest-first (or in `order`) until `need` is covered; the coins inside their
+ // timelock alone if they can carry it, else everything
+ _accumulate(spendable, tip, need, order) {
+ const desc = order || ((a, b) => b.amountSat - a.amountSat);
+ const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc);
+ for (const pool of [live, [...spendable].sort(desc)]) {
+ const picked = [];
+ let sum = 0;
+ for (const v of pool) {
+ picked.push(v); sum += v.amountSat;
+ if (need(picked, sum)) return picked;
+ }
+ }
+ return null;
+ }
+
_selectInputs(amountSat, tip = this._tipH || 0) {
const busy = this._remoteBusy();
+ // a coin at the server's depth cap can't be an input: the cosign is refused
const spendable = this.state.vtxos
- .filter((v) => this._free(v, busy) && !this._expired(v, tip));
- const single = spendable
- .filter((v) => v.amountSat >= amountSat)
- .sort((a, b) => a.amountSat - b.amountSat)[0];
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v));
+ const single = this._spendOrder(spendable.filter((v) => v.amountSat >= amountSat), tip, (a, b) => a.amountSat - b.amountSat)[0];
if (single) return [single];
- const picked = [];
- let sum = 0;
- for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) {
- picked.push(v); sum += v.amountSat;
- if (sum >= amountSat) break;
- }
- if (sum < amountSat) throw new Error(this._insufficientMsg(tip));
- if (picked.length > 24) throw new Error('balance is spread over too many coins for one send');
+ const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat);
+ if (!picked) throw new Error(this._insufficientMsg(tip));
+ // The cap is this wallet's, not the server's (which caps offboard and
+ // board inputs only). It used to be 24, with an automatic consolidation
+ // round at 20 coins to stay under it — a round that cost 0.2% + base on
+ // every coin and was 84% of what users paid in fees. Wider, no round.
+ if (picked.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one send');
return picked;
}
+ // Inputs for a Lightning send of `amountSat` plus `routing`, and the fee
+ // they come to: { inputs, feeSat }. Throws when the coins can't cover it.
+ _selectLnInputs(spendable, tip, amountSat, routing) {
+ let inputs = null, feeSat = 0;
+ // A wallet that renews its own coins pays for every renewal, so it
+ // spends the coins nearest expiry first (Stacker News, 2026-10-29): the
+ // ASP takes them, so nobody downstream inherits the short timelock, as
+ // an Ark-to-Ark send's recipient would. Otherwise (and on a tie) the
+ // smallest coin that covers it, keeping the balance in few coins.
+ const oldest = !!this.lnOldestFirst();
+ const exp = (v) => v.expiryHeight || Infinity;
+ const bySize = (a, b) => a.amountSat - b.amountSat;
+ const byAge = (a, b) => (exp(a) - exp(b)) || bySize(a, b);
+ for (const v of this._spendOrder(spendable, tip, oldest ? byAge : bySize)) {
+ const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing;
+ if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; }
+ }
+ if (!inputs) {
+ const need = (set, sum) => {
+ feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing;
+ return sum >= amountSat + feeSat;
+ };
+ let picked = oldest ? this._accumulate(spendable, tip, need, byAge) : null;
+ // too many old crumbs for one payment: fall back to largest-first
+ if (!picked || picked.length > MAX_SEND_INPUTS) picked = this._accumulate(spendable, tip, need);
+ if (picked) inputs = picked;
+ if (!inputs) throw new Error(this._insufficientMsg(tip));
+ if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment');
+ }
+ return { inputs, feeSat };
+ }
+
_insufficientMsg(tip) {
const expiredSat = this.state.vtxos
.filter((v) => v.state === 'spendable' && this._expired(v, tip))
.reduce((n, v) => n + v.amountSat, 0);
if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`;
+ const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0);
+ // under dust a round won't take it either: only new money pays from here
+ if (cappedSat) return cappedSat < 330
+ ? `insufficient ark balance (${cappedSat} sat have a history too long to spend and are too few to renew; receive more to keep paying)`
+ : `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`;
// coins a renewal took stay unspendable until its round confirms on-chain
const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat;
return renewing
@@ -1307,9 +1548,13 @@ export class ArkManager {
if (expectNet && dec.network && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`);
const amountSat = dec.amountSat ?? userAmountSat;
if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount');
- if (this.state.actions.some((a) =>
- a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) {
- throw new Error('this invoice was already paid or is being paid');
+ const prior = this.state.actions.find((a) =>
+ a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed');
+ if (prior) {
+ // two messages, so a caller can tell a settled payment from one still
+ // on its way (an NWC client once read "already paid or is being paid"
+ // as paid, hours before the money moved)
+ throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress');
}
const tip = await this.chain.tipHeight();
const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat);
@@ -1317,24 +1562,10 @@ export class ArkManager {
// fee — else gather largest-first: each input funds its own HTLC vtxo with
// the same payment hash, and the server sums them into one payment.
const busy = this._remoteBusy();
+ // a coin at the depth cap can't be an input: the ASP refuses the cosign
const spendable = this.state.vtxos
- .filter((v) => this._free(v, busy) && !this._expired(v, tip));
- let inputs = null, feeSat = 0;
- for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) {
- const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing;
- if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; }
- }
- if (!inputs) {
- const picked = [];
- let sum = 0;
- for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) {
- picked.push(v); sum += v.amountSat;
- feeSat = lnSendFee(amountSat, this.info.lnSendFees, picked, tip) + routing;
- if (sum >= amountSat + feeSat) { inputs = picked; break; }
- }
- if (!inputs) throw new Error(this._insufficientMsg(tip));
- if (inputs.length > 24) throw new Error('balance is spread over too many coins for one payment');
- }
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v));
+ const { inputs, feeSat } = this._selectLnInputs(spendable, tip, amountSat, routing);
// per-input HTLC share: first n-1 consumed whole, the last carries change
let remainingLock = amountSat + feeSat;
const parts = inputs.map((v) => {
@@ -1491,9 +1722,16 @@ export class ArkManager {
action.step = 'initiated';
this._save();
} catch (e) {
+ // the HTLC already went to this payment: another context (a second
+ // wake of the phone's worker, seconds apart, over its own copy of the
+ // state) initiated it first. That payment is this one — follow it.
+ // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.)
+ if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) {
+ action.step = 'initiated';
+ this._save();
// INVALID_ARGUMENT: the server itself deems the invoice unpayable —
// retrying can never succeed, revoke the HTLC instead
- if (e instanceof GrpcError && e.grpcStatus === 3) {
+ } else if (e instanceof GrpcError && e.grpcStatus === 3) {
action.error = e.message;
action.step = 'revoking';
this._save();
@@ -1520,7 +1758,22 @@ export class ArkManager {
this._save();
}
}
- if (action.step === 'revoking') await this._revokeLnPay(action);
+ if (action.step === 'revoking') {
+ // Ask first: a send can land in 'revoking' over a stale copy of the
+ // state while the payment went through (or is still going) — a
+ // settled payment is settled here too, and one in flight waits.
+ const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null);
+ if (res && res.status === 'success' && res.preimage
+ && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) {
+ this._settleLnPay(action, res.preimage);
+ return;
+ }
+ if (res && res.status === 'pending' && action.htlcExpiry) {
+ const tip = await this.chain.tipHeight().catch(() => 0);
+ if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; }
+ }
+ await this._revokeLnPay(action);
+ }
}
_settleLnPay(action, preimageHex) {
@@ -1765,7 +2018,7 @@ export class ArkManager {
if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch');
if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested');
try {
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] });
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'], acceptExpired: this.honorsExpired });
} catch (e) {
// a granted vtxo can anchor to a round the chain hasn't confirmed
// yet — park (no preimage revealed, nothing at risk) and retry on
@@ -1981,7 +2234,7 @@ export class ArkManager {
if (v.policy.type !== 'pubkey' || v.policy.userPubkey !== expect) {
throw new Error('refund vtxo is not paid to our refund key');
}
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect] });
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect], acceptExpired: this.honorsExpired });
if (this._addVtxo(v, bytes, refundIndex)) total += v.amountSat;
}
if (total) {
@@ -2104,7 +2357,10 @@ export class ArkManager {
const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop();
ppmUnits += v.amountSat * (entry?.ppm ?? 0);
}
- return this.info.refreshFees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000);
+ // the server waives the base fee when every input is at its waiver depth
+ const waiver = this._waiverDepth();
+ const base = waiver && inputs.length && inputs.every((v) => this.exitDepth(v) >= waiver) ? 0 : this.info.refreshFees.baseFeeSat;
+ return base + Math.ceil(ppmUnits / 1_000_000);
}
// manual: the user asked for this one (the coins page) — it earns a
@@ -2244,19 +2500,20 @@ export class ArkManager {
if (action.step === 'created') {
// A set the round would refuse anyway is retired here, not resubmitted
- // every sync: an input past its expiry (only the server's backstop can
- // renew it now), or an output the fee leaves under the 330-sat dust
- // floor (a 21-sat coin with a 150-sat fee sat "in progress" for days).
- // Nothing was spent, so the inputs go back to spendable.
- const tip = this._tipH || (await this.chain.tipHeight().catch(() => 0));
+ // every sync: an output the fee leaves under the 330-sat dust floor (a
+ // 21-sat coin with a 150-sat fee sat "in progress" for days), or an
+ // input no longer held. Nothing was spent, so the inputs go back to
+ // spendable. An input PAST ITS EXPIRY is not a reason: the server's
+ // round-input check has no expiry clause (a coin 3,127 blocks past its
+ // height was renewed on mainnet, 2026-10-14) — renewal is the one
+ // thing an expired coin can always do, and this gate used to retire
+ // the Manage page's own "Renew" of exactly those coins, silently.
const inSat = inputRecs.reduce((n, v) => n + (v ? v.amountSat : 0), 0);
- const expired = inputRecs.some((v) => v && v.expiryHeight && tip && v.expiryHeight <= tip);
- if (expired || inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) {
+ if (inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) {
for (const v of inputRecs) if (v && v.state === 'pending') v.state = 'spendable';
action.step = 'failed';
action.superseded = true; // no row: the renewal never began
- action.lastError = expired ? 'an input has expired: only the server can renew it now'
- : inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum';
+ action.lastError = inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum';
this._save();
return;
}
@@ -2419,28 +2676,44 @@ 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
// policy (how much margin, how often to ask); this owns the bookkeeping.
- async ensureScheduledRefreshes(marginBlocks, maxCoins = 8) {
+ // grid: the order's height is snapped DOWN to a multiple of it, so orders
+ // from every wallet fall into the same round instead of one round (one
+ // on-chain fee) per coin. The snap only ever moves EARLIER, by under one
+ // grid step — the caller picks a margin+grid that stays in one fee bracket.
+ async ensureScheduledRefreshes(marginBlocks, maxCoins = 8, grid = 1) {
const tip = await this._tipMemo();
if (!tip) return 0;
const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds));
+ const runAt = (v) => {
+ const h = v.expiryHeight - marginBlocks;
+ return grid > 1 ? h - (h % grid) : h;
+ };
// A round output over the server's cap is refused when the round runs —
// long after registration, with nobody there to hear it. Don't register
// an order that is going to die that quietly.
const cap = this.info.maxVtxoAmountSat || Infinity;
const coins = this.state.vtxos
.filter((v) => this._free(v) && v.expiryHeight && !covered.has(v.id)
- && !this._expired(v, tip) && v.expiryHeight - marginBlocks > tip && v.amountSat <= cap)
+ && !this._expired(v, tip) && v.expiryHeight > tip && runAt(v) > tip && v.amountSat <= cap)
.sort((a, b) => b.amountSat - a.amountSat)
.slice(0, maxCoins);
let n = 0;
for (const v of coins) {
// One participation per coin. A shared one dies whole the moment any
// single input is spent, taking the untouched coins' cover with it.
- try { await this.scheduleRefresh([v.id], v.expiryHeight - marginBlocks); n++; }
+ try { await this.scheduleRefresh([v.id], runAt(v)); n++; }
catch (e) {
// priced from a stale schedule: re-read it so the next pass registers
if (/insufficient refresh fee/i.test(e.message || '')) this.info = await getArkInfo(this.arkUrl).catch(() => this.info);
@@ -2450,6 +2723,35 @@ export class ArkManager {
return n;
}
+ // The worker's version of flattening deep coins: it can't sit through a
+ // live round, so each deep coin gets a standing order for two blocks out,
+ // one per coin (an order dies whole when any input is spent — and a coin
+ // that is spent hourly will void it; the next wake registers again, and
+ // the one time the coin rests through the height, the round runs). Only
+ // coins still inside their timelock can be scheduled; an expired deep coin
+ // waits for the app, whose live refresh the server honors.
+ async ensureDeepRefreshes(maxCoins = 2) {
+ const deep = this.deepCoins();
+ if (!deep.length || !maxCoins) return 0;
+ const tip = await this._tipMemo();
+ if (!tip) return 0;
+ const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds));
+ const cap = this.info.maxVtxoAmountSat || Infinity;
+ // a round output under dust (330 sat) is refused, so a coin that small
+ // can't be renewed on its own — asking only spends the wake's budget
+ const coins = deep
+ .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap
+ && v.amountSat - this.refreshFee([v], tip + 2) >= 330)
+ .sort((a, b) => this.exitDepth(b) - this.exitDepth(a))
+ .slice(0, maxCoins);
+ let n = 0;
+ for (const v of coins) {
+ try { await this.scheduleRefresh([v.id], tip + 2); n++; }
+ catch (e) { console.warn('ark: no deep refresh order for', v.id, '—', e.message); }
+ }
+ return n;
+ }
+
_dropScheduled(entry) {
this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry);
this._save();
diff --git a/src/ark/proto.js b/src/ark/proto.js
index eb89d5e..849aaac 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -416,6 +416,7 @@ export async function getArkInfo(ark) {
if (field === 13) info.minBoardAmountSat = Number(value);
if (field === 15) info.lnReceiveAntiDosRequired = Number(value) !== 0;
if (field === 16) info.mailboxPubkey = hex.encode(value);
+ if (field === 19) info.maxVtxoExitDepth = Number(value); // past this depth a coin can't be spent until refreshed
if (field === 20) info.maxOffboardInputs = Number(value);
if (field === 18) { // FeeSchedule
for (const f of pbFields(value)) {
@@ -445,9 +446,10 @@ export async function getArkInfo(ark) {
info.offboardFees = ob;
}
if (f.field === 3) { // refresh
- const rf = { baseFeeSat: 0, ppmExpiryTable: [] };
+ const rf = { baseFeeSat: 0, ppmExpiryTable: [], deepWaiverDepth: 0 };
for (const g of pbFields(f.value)) {
if (g.field === 1) rf.baseFeeSat = Number(g.value);
+ if (g.field === 3) rf.deepWaiverDepth = Number(g.value); // base fee waived when every input is this deep
if (g.field === 2) {
const e = { thresholdBlocks: 0, ppm: 0 };
for (const h of pbFields(g.value)) {
diff --git a/src/ark/validate.js b/src/ark/validate.js
index fae3465..9cb1751 100644
--- a/src/ark/validate.js
+++ b/src/ark/validate.js
@@ -24,7 +24,11 @@ const fail = (msg) => { throw new VtxoValidationError(msg); };
// plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics)
// allowPolicies: acceptable policy types (default plain pubkey only; lightning
// flows pass ['serverHtlcSend'] / ['serverHtlcRecv'] for their HTLC vtxos)
-export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'] }) {
+// acceptExpired: the server keeps honoring coins past their timelock (coinos:
+// captaind allow_expired_arkoor) — the coin is then an IOU the server has
+// already swept the tree of, which is exactly what the holder chose when they
+// left custody to coinos; refuse only on servers that would refuse to spend it
+export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'], acceptExpired = false }) {
const serverHex = hex.encode(serverPubkey);
// -- structural --
@@ -41,7 +45,7 @@ export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, a
// and the coinos ASP keeps honoring them off-chain after sweeping their
// round — an explicit IOU where the trustless path never existed anyway.
const tip = await chain.tipHeight();
- if (vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) {
+ if (!acceptExpired && vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) {
fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`);
}
diff --git a/src/assets/zap.mp3.base64.txt b/src/assets/zap.mp3.base64.txt
index 6f056c5..b9b7d5b 100644
--- a/src/assets/zap.mp3.base64.txt
+++ b/src/assets/zap.mp3.base64.txt
@@ -1 +1 @@
-SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAFhpbmcAAAAPAAAAOQAAIuAADxEREhITExYfHyUlLS0yOztGRk5OU1dXWlpeXmNsbHJyd3d7f3+Dg4iIkJWVmZmdnaGlpampra2wtLS5uby8wMTEyMjNzdHV1dnZ3d3h5eXp6ezs7/Ly9PT39/r8/P7+////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQDgAAAAAAAACLg/oQQbwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//O0xAAcwBJlvmBAAQBk3JLbt/wH8DgAAAAw8PDw8AAAAAAw8PDw8AAAAAAw8PDw8AAAAAAw8PDw8AAAAARh4eHjwAAAEAGHh4eHgAAAAAGHh4ePAP/9+Hh4eAAAAAAYeHh4eAAAAAAYeHh4eAADv2Hh4ePAAAAAEYeHh48AAAAD0pyXfgNmWNGgESJEiVESJEiRIhBQoKCgoJBQUFBQoKCgoJBQUFBQoKCgoJBQUFBQoKCgo4FBQV3hQUowAKqQimoPXYSgFJq9unDgTEcFy4h69VVtuUyf5hBBhgBAFcHofCq5ODzmW6vw3DevrVKOSqsjw7Ork+xZv/u//+/Rv/9XIRmP//+k87f///////////zzrjnjnYQH///+PA+hRYY3QbhYbJeY/9uy0fACCPCgMYCRGL4qbIBTNDCEo8jPoDTjMMCCACeEOnDGluTC//NExNAO2EJRuMmCDag2wYt+MhEJcJEIhAU1irk7DNg0GBjoYUSIHgC2H1eBhgCADRIFBEwJGJHwMUAxxMSSxRe80+oYXVUL4GbAiAEsWKO8uZLKMJ/0FFFWScgtRNb6//MUxOcBYBZdQJiAAucWIPEnQ4D9wt/2//MkxOwESBJeQVgAAJ0qsutE781csWpE8K1zEBlQtPZOuSWo95WI9TU9NulrRujt//M0xP0S8zZSNZsQANLPTPxaUvs2jiKYN5IZf///yJ44Ei9Lr///+hxe+lu4/////hUjD8Mvf+f925El4rizG7n/8827XHEd//O0xOxOvCKbH5rSIEf/+PvG35YY/H/923SXrtb/u3qDd28XIaBONwhN+Tu5K0Ab1PIl+nXPyD///swBDF6tPy2jh6Luu29HE7VX///+cwpefPV5dKuSimkVX7iGlTc2aY/3kYCeDACGFISw1UMDgOBD2dT7fR0sT4fjPPCiNjZM/YE0Sebk946hsLDcUx3h7J46xyeNjxGNTcPY1qk81GxhAG6Z4kjraHuSQTGJxcPeO9xoszSeoY084QBMSTXbZeoymXKd+xjH3HTKYx7+7Yz5fERHvk3Pv//Zve/hj7v//2U9NM3fBxjGzfTGMpjGfvfbqqmH7t73/y9N753vZ///Dz75erEm7WppjvJ7m2aUbu7EAEdqqpWEWHWIX/b5gJvUqgGNXkLdkmcsVSpxq81SwU11JJRclA5UBryvw1F5dTxZ3lD1TSkQCbnBwOGr//OExPQu0+avH89YAWNdhTQHOk0nmuwRDrS4StIeoqIkCjU+qL7jOSuRYrtA062nvR/eR1i3kRGWCDZUkNdQmY0QizIeoTAQTaPhxeEzQptL45uZAEJfBq1wogbjs5heFyRI6jIPsjjdE+Jqdw7zGXYrji1lAuXxO1xAJYxzEEfKgsZuLpGqpVnAkkq9VZDMgbyVrm2VFG0HUYvf/9/lJJpkkXVliiuUutFy7MzSzUC0ZTU9+lpf2rieTZlDUGTX//OkxOtDtB6zHsPTjK6BfUE5o0Cjj5GJUYOrGA8q8Nh8qIzChIdQticqiFKBXo1EsniKiJf9/9bAF20Ps1DcBkKq4O0vigE5P86wgx+H4ETijrWHO8tDcgTxwUiY8Ekd7R5N1zZBt1JcwgiaqSV0CMfxvNDogD5KNESoim5BiETCo2aVExVpDuJ5+TdlFFKFLNFzz60LdLNZMmNWr56KO3rNSX1as1jSZJqfcavXLmUZH9auXq7EoYk1dtf//819zsj49v/39ff3H///W29v3HUxX38NiKZLpttXTGOp6NptegERR1V1WVVjTff7+7SVyuJJIJlh5CJHVYZJ1vgFQOYmI1hIRyioFiUyw9mDcw3icMo014SQLk/0LPEn5hHU//OExO8tDALS/09YAXwapB2Mn5pGQwMKkIUmSdn8h7Kl2RJzrTIyIs7EMOgt50GUWE8lSYx4oY2pdSsyw5qBvSsdOPlU0H+oS/oYr2ZQSuCnhQ5meK5MkKI5vUMeKhHv1epFlankcmSKwMLM4qtghdneqZlbFSj1HBcorFBwubYfLFMxX7Jlsh7on3z6BuktlerIbp28tFjws/cBziNKgV8OM63ef7ppqmfrKrhO4kXEu///l7PAs9jx30XUO0d5//OkxO1DTCbC/5h4AX1//5GzcdFszIqHrnPGpjf//zfor0QxTp5C+oXLrzQAKmZoqXh5LbXZ23KezGGU1crMcWzZWsyglN9gTeJgFqw+On+05j+GZzMgCwNsaDCQc2qsMvvTE1MQSIBElxccLDChwUvMmEDHkUIhRGq0VjLKAEgOdUgDDzPRFolb1giZRs1QeHAhItWnKYIZeMrDQhDD4FAyqDgBHWmBSS0hQiXrTXVoLtpIKwF+ihUIgL7omMwTyQwbAqo+604ykGj4wxD5WxTBLRe5ZhKhGpNxZBftGdFNQ1KtS5H9H53E41DKVXDsM1Ug5kkUgu5bCTTOlTJNrPi7kwAxRuz2N+uSu+cwtNyF3xWFrUh9T65HPeaaUDh+//PExPJhvDKS85vIALrWY80NHN95hFqXS4uXL7zoSF+1EkyJEnSierx5otebC7GdlW+86LA3cYeyJrCzqTl66yWr1eMDyxaK71uNhXuprXoae0ze1f+SyJ0FMqLqykI1B4ba2mlA1l6HsvR/H/9qsQKgSzoSvBDgmooOxSPv+kjlGkvZWw6m5/WYrcJQIPpUnkHiQR7kPEOlMl9cnE/Kyas3ltuu5qqs8LM0cbTupGf0vG/Bb9piQY6FriPaPitTJCqFF8RET0FbEMCEPVCS47h9BHy6kBLogTJ0LeVxelACcFhJyqhQga4FApB3JZXOc5jC0AKI9BCAjJyHgwJovCgUB3FyJIPQiFGY5Xmg1IYq4EBTqVheqNM9PplfQxzUrD4DVOi1WqT9XKgQh6olJEfWcle+VbI5MTehr1geKRPrKzH7C/c47pXv6qxie6RqHv2NEPoemlpdKhUqvNmyMxUbJ3jAyK5siO96mjySdsjN7HGcYcd5Fu/vfPiRdSwW//OkxN5FLCq6fZh4AQeUhxtUxNPf6cJI+JH8Cj+2G3d4Pd4/+oFob3tr2DFq6nq9255r/lYVjBar1zdu3C8aWC1uqSqIe2ZWh0/RBylagZA6rAVFFoPwT+USW7cbNV0TmM3s19O4cVHGhECNALuc1rEHj7sfzUl1l1kXkckLJ9RUFVccpKreZ9/ez8/Pz8e1d/XFfVxdJ1XL+NLREt7cOJvRtVYq1f67Vsqhwkpx4i8aYAri+RsKFFj1LeaUZA92m2Z3iF/hK0BoQSFYuxkQwjaFThNSPyXWjFcmVEjKw+oDg8dA8lZJLcadBjjO7NrE8sPOFMwvDuwmQUoLBFR8E0IkUSTM6DnB1DkRtcu2dkbaCkQ9KkfdXLY6p9p/9Ur6//N0xNweugrPFc9AAC0udWNmz/6sqMzkItSKuzrIi6nLQyv+31NyKxlYR6ipWazqEZV3eVllx09RXzxhB9IDY51ljIKhLkKTJENVyLuP55FPDWGnS66ETu2w8oDgGTLZAZyzuWMk+mKW32BGde1E2P6fTPlWFyQ6WbPTbSp2VGqzNDKUxYW20SDTpKoEuXhUu4wKpjNPJ6RA0gUC4MEgqeEtsuR/ytQ3//NkxPwhVBbTFHpEsBY9DSrIiWhot0kiDbmGoYoI0aly6rchVM+Dqo+Qauayk9HWoKD6mmJBrLGWmDLFQR3VpRnBvDYJofEUGIpmRkBtclnIahsuTbdE01223pgtWttm1QqmUm6Bnb0UmRbtGpq383pRUaejzVV2Bsog1/I06EZG3+nqEI7Kd2CMcgMeQ8XV//NUxPkeSfbTFHmFDAhGOHDAdZVmWNfZEiRfKw6wgZqQDkS+yf2iGjvJyHITAuNFIyzw3ctplzIBwP5G6KsizUOSg1Dj7sxDxyJ5/NzI6mtxvv5T/ovtULXV77V9aXn+/pSXcfPx8fPcff1Xf3/o3Pz/HfE8z/3X//NkxOohHBLjHnpE4Vdt9/UdzHM1EWozm6buklJ/49/93aGvrgftfdRtadX3zGSj2QWyfXOIyRLIidsqlAouNKktwm6tBPVqicpgIW8SoQCPej2AALZf9eqw6w0OO2lWo8wRdrS5idXc1Zf9VSlkxEWTvVHWXxSFwC9DQHflq/n864EsaBOwukZXUiUOOXFZ//N0xOgiY/7S/U9AAXyaanInG5Y3kxi7dFflEqr5T0Vpn8dLJ28aKenKaUUNiIV4FvyCtM2qa1GLU7Is41IbERn4blT+VL0nlupurPSypS279z+W5PdjligryuV4x6puxuWfUzp6/3K0/Say1Vl16UTNatOU9e5K888f+7TSuRS2xN2rdFjfu//fwmLNHS2r1Nev0eXzMx93OY7Zu3YrNyt/Kfn/hSc+//OkxPlFZDK+d5jAAG+TUQnstdxu2tUvIp//cnf+h+5GrH//67rG5GcVVlRpVIRmmGRDkiTKCaSLjQZjQgKqdEFCWsRnaYCAcsiYQqUVGlMACLuoQTt4cxdNAa5/wjgLuegRGVgwww6xlIQRMoND2c1EgeCqPhNMKrQMyCcycPVRBLeWxCEeuFyW86FWXtdMandK96/UjZCZGdtTM92eKaavZW9zRLxrWIMOC5zOT5SJtugKZUMrg3u4l8Q1ty2wLETTt5Hhud6RlbDeR3N4q2ueH37Otu1XDqtxfDvBTl3m/Tbm1x3F2yUgXiPMfz6WYTyLAb3OJGh03/FxGhx40k7Pv4v/5WCPN////1Vj+77/COPFfm//+/8zV/a5v9nP//OUxPY+fAbHH5h4AKh61yCmVVVphE7hInIQtdCxWLU6stvrd7m301abWzWgWs9MSrPDOTmdsmeNxuPnXoN2MJIVEo4eWiHml3J0QyiHz2Luac5phpSTY920xzITRdU042qfqYyMYmk481TjVoh7I6qfc3tqfV6vRqOaa//U1EWh/pmWM3zlNM33q//vZ6t18///lQeHvQyYC8RAGFqnVVlZlUqWgVwR8UI0LrLJHhDn9gPV9v5qUJr51W83BrUKupBYgq1UiFrXcItSgwB0qTkOL4M7TMlzUjWkwJZK7Z8qMMurnEq5nb7N2Te7XK0b//N0xN8jNBbXF9g4AHpO3K36Eb6aPv/77vf7M++2Tbf1f/2r////9pHneDcLZYNIeFVPVhlZkCgM1BTEIaAVVunJ+ZEA/xDpjABevFKRzyDFYUqCs5KHEyyssbhV4ZknRMUwBUFVniasjjeeoPSopWdD8zkMwcpVaWU+9G9HcFqiMZyJYsvq0nz//yZMOWqGZ1OdnZGVXQru0quCOsb1m3/iX+MCwQKk//NkxO0chA7bFMDFEQYY2jl3scRB+o+ogO4sDGY6alMF4/UeHiLnbSkbBkKV82lpBXbk/f6671hyICdx9FMMKRBJgxOKJhZanXmrKXC9HUyRNelczItDgR6FNEKtEz9IcgMwwSN0u/slDJQouOd2HrUv2X8zzKNGCY4KGjzPsLj6OQgeIKB2RESj3iU3//Br//NUxP4fQybXFHmE7HB1QNGAYu5nVhlThmjIeOx3LFj19DsiQy4YunsLhCZD2Me6nOBmYM2HJ9VoKWgcvjIGH7CPesrdDW6u7+6qbnrrm7j3u2iKuYmU94FqYqmelMuFv9Nxx+1UNupmkaT6S7/mOEmf///6y8aP//NkxOwhswLSXHjLGKit02pSe09PeomRj3jtbouK0T//7FPqoIp+b5v+4SLJHno9W40fYcYsI5lHJBJ4hhQdhmdVhVaEhldVrtijVkYVSLSAcrukwVMS/A0MFDSvbukOw0wJa1JlK1lRFDlAMaZUtBAWtGLuQj0qVgwFJBL0RRXMCOXFmnIeogM6V20F1tvx//N0xOglvBbWd09AACdzHFizrRiD5LF4TSP04E5PRjCNwBSu5ucizkNleqOvDJnLicql0sxg2ITuUrpMqtqNO5BNeHIftQ7efafgS9F4pRRaBIrQ37Utjd2WXZu67TtQLHJbOvA/s//wqlrVdx6rczrfuMX9Qrkxf/eP15XDEMu9IYJhh37UVkTzUMQz7+qm8pq7clVDNXKv/+ec/KIDjcsh+VxihvVv//OkxOxJFC7LH5jAAP1Y+68MWk0fi11/mdMDaxFV7M1bi0pst7/5/4/r8d40MczoZqX1ZRW/////Dn//KGNdoYlFYZtWFc5DImVoAA4NRlAe1nBMJpjhVNSUT2Z4q5MqY7TG3mB16KPCQYcBHjXiuLlOBJxagq5HYSWr3Ldr5aNGOo1JknZkuW46w9xroV5sUcpKFx9HrputKL8///ij+KS0EhzCosZnhY1xJ1FhqMmUSlG+Zfh5/+r0KX/dFi9VKUzmHXYwcxso1VHlkXljZa4pJEsaeTEsHWHcpWqDvUezyhuUVo5YqtAkFQMdWASCAtXKjmd0mchPGuXEsrTN3c0zwrYj4MvJ7K5n1VyXstViqLM8HldQe+VY7LJHOtrS//N0xNohtArKV88oAZWdmTepv/p5kkypahXQdiO5TurSrf7urN//JvXX659Z0UyzzkJI6sA0JA05JcWqlXNYrteiTgSTUK4oV78Vt52099fDla9EYg+i6aeq+Wf3u5XrEf/s3hun1yQasCZNKT+OGEsFCPZv5GgkJ6Qj60+Znn2yQ0zK7R0mMUHw74eq81qWo5mSwsEUer//2o1KSzXBOo+Kc853qavX//NkxO4gS97a/MGEvf/96bbeqPY98yrOyMVGUjlQjAkUiFHpKRLEUzSOxONLchpL3oMPn2WhNYcjATpMGtwOxWHc7stx9ltvJNxqDc4O6OezWIrCM+LRFdSOdJyMRVELGW+6zra6EejTttO7s9nUshpDIR52uzO1tv6ZM8p5Xey2KQSXMqzqipRH9fp//X////NkxO8hM/re/sDFOW33s8yTIxkMFqJlcfBWefs1rbJEktiWHwHQxrB8Yiki3lYzqai+pMLqPazhhji9gxRixgTEAKFCEFNWGN5h2KEZ+ckqmtcihvN/y3LeS2nV1TZ/redv31lOkf9dvY0Ov929NGzWNI8mtC0Euoqz+v///v///XTfHq1Wg9Dhm/Mv1ZX///NkxO0eO9be/MJEPUmrscbbYgZCEcE5qg3kZHGjTCXiYZ/MUtoSb/m8usQol2/yq4c0TUlBkBs5TQdy5MSiiEV3Rod3Z4K7YRI9SXdwTsp0BTJkzTPV2Q6GaQ82dJqP/TtXNn+8M7mGYEORKv9MnsSp//7fav0y7So8TKNBnqRQbS9kGxXSLazRxpLYnCAG//NUxPcck87eXHjFHdJTZRxdnJrDi7ioiC8NO78xvhxuv7lY5qRZ9ws9uvYWRDHCoOwEensz3UeY3fnVdA0O8ORdc1GWuQLKXOGt3hJcdAZlHzI4XYSeNl8+vwnvp1ck4dz0VLEN6r/ttXv6b7/7Otkolk3S015D//NkxO8edAreXovEHZbrkzVM5GUolbNTRfpJ8sxIk2guKEZxGF6ca3G1bpXF4/IQakxuynKx8z+NTPfx72vTA7yF3u6AmgiwdQ0gJEWuCI5HKdkYtkvsrMx3o1NdNNapVqOq0eiXff71fazoahsttcz///19bgj0z7bbtbLV6Kq65LncwtlG3otdndGi2x4q//NkxPgfZBLWXHjFPIRUZ9vW0lIiCYjKD7QNjAVUUkOappvjsT1tOCM9LTVGYHDSM4atWVLXANbjGU3qWUR9LDlQXg5qTDnkTFgjoMtj28bj8fvmIo42hqDOhyjMQmUTgj1sCmlgYdGRskOMBoreVnP+7tUGA4h6BfXGJW4xtasfHTyHwiBxCEb5zbXSf3Gg//NUxP0eTArS9MMEXWzmVRvLSLBO0HKweSNDuQBG1hLdO05qAalUFYipwxInUGMMeuNZBE6njhKw9BErIKzDkCkdqiOUph9jUdFZFYytNVlZEtSqsowx7863dWbvYy3UrLZLCSd+3//6T5MilIvtT/vfXf10betV//NkxO4e8gLO/npGzMiJVkpUrUeN59bL5tJHE/icYofNbmGldofZlASPo2jIM8gmbKMYbu07GkdyCfLeeq3aa0/KkhTGCOzWEogyxkQpAQlIiCKdqPchZEuqsq2K7L2Z0RUu25KJTe9Vbu2VWbW7vKXEo//0/9ULvUq1a/o+/ps9LN6tt6OxZ1ZxrFoLKGlG//NkxPUevA7OVsJKOZ3VZFRImrdabjANYYgqbuYN3qGxcqOSsi7Xzq8kfDc6xCxZdxYcPWYddOHyN0TAQrVhxhRiw6DljtqSLyQF4dd1gtIJKBSRTdtSgfwiaeU3LO1iiWLsTuer0vY3apHXR0I9s2P////7b5q3TSqbZ8ezrc5B/2FunB3fEwFxMbE5pYZC//NUxP0e4+7KXHsEXWOXNxNSIgxeJFUacU83CXZQV+Mouy2ENIZuHYoNl4cJTy6n3Qvsb2Rfjv/hlYaexdkj0UfV1EHYLHRuXNh6j1ZbRqYqUyIdmSwrM8zERqsr3M53vdhyy7WWJQ5H/7TjgavVdct6b/UxzXQo//NkxOwfo1rC/njFOS7AkWLQMYMmAuG1tVKG30bcdkQZM6Il7VOcGyaxeP8LpyAHqxCqIxNFY9U2IiBu7jnvA9va2m7trnejRjxK4+g7Fo1IweYLKpHYYlbBlSSDkTGiyiJKrRLtFjE701ojNXR6yq8imOiFOSqXUp9f//9FeaqzGel/t/L96N9+iFe5Akcg//NkxPAd4i66/sGKmJKqqxFKZRIOOpiI1q0urbTjkQJD2wGYCP0iko5j81y2KRVm9BgNLUu4k8kR9EnMELG3OTbhLEGrieX8aPt6g/m5Uzk/k2bSBh2NeBJe/JSwG4Y2oddrZ1Cta7ylRabmfI7as0rIj90eYrojGXRBxsyv0//q+61dyqrXZC1/75v8ys63//NkxPsgw/q2/sJKXUm7apZamkq7GdjxiXWVRnBa2rjkcsRTZ9EghpU6zNlulJft94jStNdVqrLr0y8l44IcqG3watgLcJV3cnDCVcDTNN9T/hGj8YCZdV5soP4XO9u0HUQWdEH6GcrPNeWbaV8VlKh/5Xc+ZEdUdbF6M5st////9qsm5vpt+n/Vm1L6Pmsr//NkxPshVAamXnoE+Vms7HpMgMteSjaASddK5Nf7Kfdgdy4ToIdD0LFh/QyztBlG2pieRxyUhUSWJJs1e0ORL4ezRs9Tlr+a5XZnsCbo7DOyfZblb/P/5hsAedmdLs+zKfDtltI+kQVFIqGZiYqvCQ+tHM0UbW/qw4QBn/foHQOQPmB8dxyGL0UERcBEAgGg//NkxPgfXAKi/sGE2drDQncsyRNmxYlVtwXk0kkt/lyMVgVMkxTvaxakXd337sUcslmMZaB5oFhssYQC0Dr8ukd8QoxE6MYSlBjaDo1v4n3Wuv0Hhl4IjtlPUrMBVGMdGRyGaUqro5k2a1WlYx2V2dpqsu1qmZQbQbXUx6m////+qM3v5nKVqde82tNGRWLR//NkxP0hCm6i/nmG3E7EAtEZguZRKziTsCpDgFn/247triIVVYdLG0qhB78hQf+3kakS83OgRs7QFYKUxeuBRtPh1uWVS6MTNid1Jys5tc3GBmf/M73JlcUr3jHZLDFZtqO52ULRs7S6omivsVrMj7JpPsizbq7F2qX///fSRTslTI+7N/QoMl6G20+XZDOA//NkxPshK/qaXsGEvKq+fnFexrdtaro3At9SU5/ZWhWSqVAa04EiFgNujNlpVwgZAMVoVfu40SQIkSJEgh62UGkr8KxqS2eG/ao5CghFAauSR0aCoomXbCpHI5+xjKqSldNkm3s5mOz5FdDGQNHRTGmK5hQUWu12Qox4Z////qio+r0dE0N/dZktppovXXRv//NkxPkf466O3sGEvUXo92dFnJFZNS6BayiSpf/a810YKt+lGTo+tJRly6lariFoIFcgqkS1gaImi9xoZh2qpWQ9U9kdhpF4x4Lo4b2NmBb54O5HIMhBq6/1Iy5GRcPBzL2JboY+9S0sb1U7fDXLQz1FRDcfKmreqj2GXYx1FQwhP1VrfA1rGg2KHrWVVPjl//NkxPwgfAZ+PsJEfcfoY0CGUNVJPQoCrMJigVXEgp7/a4fB0EfkuoW6DJRluAvRXBB3U0kWK53EWma5orwfJNjkfIbnewqPGupiWwdMztTvQ1iP/yX7EeIGab2rPX5vZUyp/WTmGh6BmbPk/Uvk6/sXyVWFF9s+3z35R1IhJyk//XnUKtl6eQNaJtyWeqtJ//NkxP0hql52XsMQbAWhe9cKAOMgAp7gAbyIFI1YIICIUCLn5ZCw61CgSaiilTwqhogtkk7XEBd+MON7DS+uskD2GG3A+ivOaSv4YzgfYhBpZvcd/jJx9b6jvqFE867QlxRvZ2TTz8SzQP4wS6kCgAQyoFW/gATgJwXDMBuB8BXDgi0A7OSUM+M+SdqusHob//NUxPkeekpuXnmHGGuWBuP8MZGtcGIkxpuJzp+eJ4XlQs1K06zVma/yyNMh0iIFjhuezHhl81KHK/zXtMC5H+ZlbhNkw7/pllWHARqGJX9r0VKBIW7l5E122AYXkXzl0RhLVHRypOV7Q3vSxMENSxK3oLWPJm2x//NUxOoXMf5mXsMQcBtSMnWVI26nrXm05t/O/qaIgldS/F9rpexNiKnR6vgwXuhbxJiiAYAEJNLf6rkJVW7TbDipjqz/fEQI3VYrt+YCdnpcErThUQzdzWOTZlfR81IubyyiKW6NOUiSmZO/F79+0wCNmEaGyjJE//NExPgXobpm/oPWHRQroj30Zwiz8jrFajOA3IYnv+ykYSw+mEAxJbB1sd6oHFZ5+5fQv/qzJsNqirW7XIhucq6czvnU0s1ep6cutEU5o3CvqWyG7ua2rQgHIMGslksb//NExOwU6ZJmWMMWeAvKrPV+t3pGWrt2Jq+D3YbLuKsHllUY5xkA0qys/t1tIUdSFvfoyDFe4P8eT5PV5mT2zBprxWcXGUJjimGgNWEclHoS1QqqV6VBuJBFWxVz9NP2//NExOsUEYpe2MLGvb1LA5ohX24EsgdxtxCZHUzD9+Eu3Tq2MqWyLCNuc7x1JFCVKhwtypXMrdddawuelhHAXTFNNhS9Zpd6abBLgXKRWMDIEMvQewUt++AAzUfRXLlb//NExO0UsYpeWMMW6AYxGU6e6uMjv4tRBr5LAaGmJjWpE0m3Yw5oZiWpFkxSgjCTCugEKi1MQU1FMy4xMDBVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NExO0R6SpeWMPQcVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//M0xPgReSZaeHsHCFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//M0xO0KgMpWWHsQKFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//MUxP0CKDY5QAhEBVVVVVVVVVVVVVVV//MUxPQAAAP8AAAAAFVVVVVVVVVVVVVV \ No newline at end of file
+SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAAEQAADYAAHBwcHBwqKioqKio4ODg4ODhHR0dHR0dVVVVVVVVjY2NjY2NxcXFxcXGAgICAgICOjo6OjpycnJycnKqqqqqqqri4uLi4uMfHx8fHx9XV1dXV1ePj4+Pj4/Hx8fHx8f//////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQDMAAAAAAAAA2An17QwwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//OExAAk7CHMAVoYAQMTFBxqHhyqB0JBulRlgi9H0THXW77/y+3nnTxiMRiMRikpOZgAgvQAEEiIiF10RERC/3d3fqbu7+jnp7uLAAAQiIiBAMDA3P/RE///c///////9/4ibgAhEREL/d//67u7uiIhf/7v6I7u/p/EREREd3c9C0RERP3d3/ocAAABELrgAAiIiBAADAwMDAwMWcDi3Ho72l3+P1gYaA5/MCltyYAttOwALBcuvBHVIRctmHBM//OExB8sazYMAZyYAIgeNSIk0T4HMo9EEY4ms4bl08SA0yfFhWzqNDNNSkRZY2iSTHGaMmu5oSaTpoBkcW9MLgximCloPemyOyhOZfPixkAPGj2st6LJ6k1JrZMwLjFxl3ZN0EK9a6mpVoLqrdBRPmykTyi4YKr2st913ehs6lLSouhNEGqWm6aVWicgIWRKi4sCA51j9RWzLxpBo0YzRSwwKFaJDm8z27HWTGAEAT8lkk5lruK4Ql9kAYCA0fzM//OExCAwKwJiX5iQAGMxoOXOukDRAkYh50GkA3MFlCkxOZoRWXxOp4lxfCcx2kMICLMFGEoCE4WFjLDvQJobA7xjhcY7hlxhkoGXCkOaHvj2gNkvHySYsMVigYGImwlRjAxkVTxBEFlA3KpaMDM0IoT0xMymfQMDRPlwpERL5r5TMThuv/Lg7zA8mbpl/0CJl8rk4s6j6RfPIo1pJ//8jCobnEK2TWyb83QLyrDaGYgJQUGOhEIC7Q4w0agUhGAk//OExBIrgnZQAZh4AKqYVKLGKhkuBAcqAFyXBIhDQfq4CNg3DCXLYWgZpPXBTF0JeSkh6EEvGAcg90EjHQlD3ItEq06y9nExqZmPtjV6BSLO2l7gl+iM6rU65c1hkjTzuKjiKym4DxWvIbRPvcPcjf9//MC1qZ1J8Q64gxfNr0+sJ5rtj3b4lf1fHZX0LGKP6bb36H+aFEu8fB41UA8sRKNQA9IADR+cmQCQBIINOPVvwFyKQY/IREMIW/jbigXD//OExBctqwqBlZh4AVVi67Cn1e5apfOLkMmYHilLyqhhhpnI4XBWSK00Y5hj1osbRwM50WPNCVCu3CoAHHmpQLbk1KhVRYLkqFw4zP4JBz7gnOzxlJNlkl8NngvYazC0/OPSFs7Iq5bXy/nxqO4Wfxozj4M07Ir7RGeSJWn+YjPHzu95v/8R8WrSFK+m36trM8syd5nU18U/z/nf/g0mg1//1jX/8O3nibsIST0CARSgBEx3Co4v0j2CwixY2ieQ//OExBMtAs6ArZh4AIi1wjM3dOkEiacgjTEK9LC2ogfQmxYC5kMEDO8/y9tDDCTIhp8HMK6ErIDd+QVnJUhI/VwLIGm5BoroWBKk9UCNQkq0NUOWFQhfIeWxZVStORYWW+IqVe2Kpc6abxlb1fO4TvmuDKv3zmmXyubdQGGs2WtsgeJFhbzjO/psZID/cLEHV//jevrM0bH9sVzN7Y36/ftSn//+v80l0haq3SIBHZl8kBid4i0YXB3GDQGkebzC//OExBIrOmJ8AZh4AJkDgbd0nwAkTIEOQ/QFcnhZCsIUJMDeL65HOLODTbxmuRbRNWQ8SUCADCMQW8UhPGqVySLiW0mw9RAhYSTrRjpwyB9IlhcFhEEtHpMFRGkrmqI5uKvvDaVWztisYW+71hj7g00yQnl4qvmla7S2kup86ta0NlbmyA3yRK/1jyYi5rAbN/ULUS/1jPv9wZYJsgBJsQX4IwGTZxnSkAGae1ADXgsEqHQYRtLhqaK2s9VpVqQQ//OExBgtoxaML5h4ASHJtw46sDsuOS4yS2H8oSxsA+DELiyqY0VpWK5pTZoLDYlE8iEcfqsN9I2cFYhhlrKcblC3RVcxrK+4H9EWzHOuO9V6rdtsCsK9odnkWW0aA4PI2KM72SDJmNmkWA16YoO3LwoMskPcCup4DljcVrmfYhVs9jfzTP3mr/DVFzLeAxv6X+NZt95cZNQtxv661n/Ns3f0f6xr/f//n8RVSgrw1FhYZaRgQk+hxTOj7b0A0qDi//OExBQnOuKAB9h4AENfg0iNk0Ww4c42R+aVTfIoVdiK2J9sJ04J+I2PLFz5hHAnT8aiGsphxEUS3JyRjQczB2woCExXeNLe5Q3l5Iq4ZdqvC4nbaYWKqfWY18YZNyzy33G083LPnPj+2v/XMsXGcX+f/nXl9t/eqWl/j/Hx//8b1/////T/vNwcf/NLRrnjJpZZGVRRCeDNWQhPLA1NxWUkSpKPCqG4rOBCBOFDuiVEExoPSNkSU0moWGU4d0c9//OExConctJ4BsPK/FWuRDmgSFFFG1LqOxGg6LM2k2JWUZ8Utq24EQoEC/MpqTNz0WXNI6MKdEs11DKibHZAfvISHyLtYgP5lBpfZ96wxwHsKKtUZdTyw5+5dhz9XuQJOFlJ4lOlUEEI8c4sUrqb+urU/xV0iSHtjBjwm9qqk5KlBfCzUtBwNswBvouIXPtA0kB3o6I4sVUVkcNMclBIKo0Z5KFgtJNzsZytN5I24Ubp2IizfFlfWEz9Gx221x51//OExD8lojp4BsMNaJFi1EZI/cwBUqOJhwToycrVYXYjsN1hhg9TQfaFiEeVzxtAr1PVkm3gfTE4HYHYY0DjK1LcV/o2IYrxLNN7q3vleEdVyJYtKLeB0SiAIMCYXNEw+xH9wCUF4J42BPTjA2jcCq5oTxPBIAjrxiKK61FV+cbDTsHeRjTmSRpslvFYn6sjsZO4qsUR+iWuQBVEwR2RmmYEZiD/ZUyqVUQ5YTjExITYx3RSTnLU+U+3FwiqOiIa//OExFsm8mZwBsPK/CRX9D8IgpsoKzk5oZXKEuBwukjK/jyLez+kZrtU3cKNus3GgxB8SjHRGI6ucQyo0T/5xJjBVlyDzCIAHg2WkP+hBWCkG4g6DLSF8hJXQAypakRA2nidhKNrqLEOq6hkUDifkJDjzbjlRzZMih5MSGK8ZCEEHQ5VhIT0HFEnDobhfq4mpzCQtZ7mwzrOmM8F2gnRTOx8PFK4sZbVCeNzsULIgrpC5dp13GuVMRNyRUrIj1Bs//OExHInYhJsBsPS3CS8pDGxPHBTiE+fdX5hQxH+/pplLK6agHFQy48GeKEgXNFjCFgJ6qXf/O6VGBM1mRiLfLSQOIQLMftClnYCa/wyp+7iLFAhDCk7ItiJhRRh3LTnEqIFGELgF+GoP0TAkQLYnIYLOiQmLHMbwxk8YZ9qUeNKaSJeTZcoJBDyPMiUWli7k2OYnUM3VMrjq5x0KkTAKIZKPQlJIkkVKCy8yLIp0pqcQtX1SofKemViRp2ZnItc//OExIcm8ipkBMPY2L69BWXP6YI5sDshhEHw2tiHh06VrQKAzVL4JlFkLocYMog0tw4FONFGVNlaGVKa2Uzr67ph9Eu0SIt6T2AZoZOTIXwoYtKVDJVoEqsoxJoWCBiC0qYL1ShUyxUC8P257q1I5bG9FBJTxLqzFtRqOS8yRPFnJrpDGVTpTBYXpCUJWWQ3odENgp04HXlsfIkJAyVp68JBKH/0rPhVNSDVzbr9NbjwdQKOrBNrRG6ulSVnheVo//OExJ4mIhpYBsPM3KAAr1RtRxZiXeWHMhUFQQNA05nNICanSF6MWr3Dasp1QstSeiGrDmqRPQ5XUFFEKOYG0SYuo/T2IUcw9TOyqZxYifBqkErZESPSdUZPKKMVRF4OUEGqkqiKsJxWAkehKHqYyJQjGQCjUQlRWEZVlzn2l329lacmR8u9l2q2szLK3rfjLrLoiLfBoGgKs7hoRZZ55apMQU1FMy4xMDCqqqqqqqqqqqqqqqqqqqqqqqqqqqqq//OExLgmCgo8AsPYnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoA1JoGdGhKGCgwjkasFDAg6B6LCtIwX1iotgUUFtQqZMhIXFaxUWD34Of/qF+tuLC4qKioqSfs/rFEGhUVJPxRsWFiILCQCigs3WKirv9YoLVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//OExJ0UKFX0FhhGAlVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV \ No newline at end of file
diff --git a/src/calls.js b/src/calls.js
new file mode 100644
index 0000000..8ff32e9
--- /dev/null
+++ b/src/calls.js
@@ -0,0 +1,464 @@
+// 1:1 voice and video calls (NIP-AC, the draft Amethyst, nospeak and Noscall
+// speak): WebRTC between the two devices, its setup carried over nostr.
+//
+// Every setup message (offer, answer, ICE candidate, hang-up, reject,
+// renegotiate) is an event signed by its sender and wrapped for its one
+// recipient in an ephemeral gift wrap (kind 21059: NIP-44 to them, under a
+// throwaway key, no seal). Relays see only who it's for. The sound and
+// picture go device to device, encrypted by WebRTC itself (DTLS-SRTP); the
+// fingerprints that pin that encryption ride in the signed offer and answer,
+// so nobody between can stand in for either side. When the two can't reach
+// each other directly, TURN relays the (still encrypted) packets.
+//
+// No DOM here: the app hands in how to sign, send, fetch ICE servers, open
+// the camera, and who may ring; it gets back the call's state to draw.
+import { finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
+import * as nip44 from 'nostr-tools/nip44';
+
+export const CALL_WRAP = 21059;
+export const CALL = { OFFER: 25050, ANSWER: 25051, ICE: 25052, HANGUP: 25053, REJECT: 25054, RENEGOTIATE: 25055 };
+const KINDS = new Set(Object.values(CALL));
+const ALT = {
+ [CALL.OFFER]: 'WebRTC call offer', [CALL.ANSWER]: 'WebRTC call answer', [CALL.ICE]: 'WebRTC ICE candidate',
+ [CALL.HANGUP]: 'WebRTC call hangup', [CALL.REJECT]: 'WebRTC call rejection', [CALL.RENEGOTIATE]: 'WebRTC call renegotiation',
+};
+export const STALE_S = 20; // older setup messages are a relay replaying the past
+export const RING_S = 60;
+const RING_MS = RING_S * 1000;
+const ENDED_MS = 2500; // how long "Call ended" stays up
+
+const now = () => Math.floor(Date.now() / 1000);
+const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
+export const callIdOf = (ev) => tagOf(ev, 'call-id');
+export const uuid = () => (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID()
+ : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => ((Math.random() * 16) | (c === 'x' ? 0 : 8)).toString(16)));
+
+// The unsigned setup message.
+export function callEvent(kind, to, callId, content = '', extra = []) {
+ return { kind, created_at: now(), content, tags: [['p', to], ['call-id', callId], ['alt', ALT[kind]], ...extra] };
+}
+// A signed one, wrapped for one recipient.
+export function wrapCall(signed, receiverPk) {
+ const eph = generateSecretKey();
+ return finalizeEvent({
+ kind: CALL_WRAP, created_at: now(), tags: [['p', receiverPk]],
+ content: nip44.encrypt(JSON.stringify(signed), nip44.getConversationKey(eph, receiverPk)),
+ }, eph);
+}
+// A wrap to us opened: the setup message inside, its signature good, or null
+// (not a call — Concord's presence beats ride the same kind — or not ours).
+// `decryptFrom(peerPk, ciphertext)`: our key's NIP-44 decryption.
+export async function openCall(wrap, decryptFrom) {
+ if (!wrap || wrap.kind !== CALL_WRAP) return null;
+ let inner;
+ try { inner = JSON.parse(await decryptFrom(wrap.pubkey, wrap.content)); } catch { return null; }
+ if (!inner || !KINDS.has(inner.kind) || !callIdOf(inner) || !verifyEvent(inner)) return null;
+ return inner;
+}
+// ICE candidates as the spec writes them: JSON of the three fields.
+export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex });
+
+// The calls of one identity. env:
+// me() our pubkey
+// sign(template) → signed event (our key or signer)
+// send(peerPk, signed) wrap and publish to them (the app picks the relays)
+// iceServers() → RTCIceServer[] (TURN from coinos, STUN at least)
+// media(type, facing) → MediaStream: microphone, and camera for 'video'
+// mayRing(pk) 'ring' (someone followed, or written to), 'quiet' (anyone else:
+// no ring, but the call is told of, as missed), or null (muted: nothing)
+// changed() the call's state moved: draw it
+// ended(record) a call is over: { id, peer, type, dir, reason, connected, seconds, at } for its log
+// trickle() false for a signer that's slow or asks each time (a
+// bunker, Amber): the candidates go inside the offer or
+// answer instead of one signed message each
+// warm(pk) a call with them may be coming: look up what sending to
+// them takes (their relays, TURN) ahead of it
+// RTCPeerConnection (tests may pass their own)
+export function createCalls(env) {
+ const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection;
+ let call = null; // { id, peer, type, dir, state, reason, pc, local, remote, offer, remoteSet, ice: [], at, connectedAt, muted, camOff, facing }
+ const seen = new Set();
+ const early = new Map(); // peer -> candidates that came before their call did
+ let endTimer = null, ringTimer = null;
+ const changed = () => { try { env.changed(); } catch {} };
+
+ async function sendFor(c, kind, to, content = '', extra = []) {
+ const signed = await env.sign(callEvent(kind, to, c.id, content, extra));
+ await env.send(to, signed);
+ }
+
+ function stopMedia(c) {
+ for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} }
+ for (const t of [c.cam, c.screen]) if (t) { try { t.stop(); } catch {} } // the camera set aside while sharing
+ try { c.pc && c.pc.close(); } catch {}
+ }
+ function end(reason, c = call) {
+ if (!c || c !== call || c.state === 'ended') return;
+ clearTimeout(ringTimer);
+ stopMedia(c);
+ c.state = 'ended'; c.reason = reason; c.endedAt = Date.now();
+ changed();
+ try {
+ if (env.ended) env.ended({ id: c.id, peer: c.peer, type: c.type, dir: c.dir, reason, at: c.offerAt || c.at, connected: !!c.connectedAt,
+ seconds: c.connectedAt ? Math.round((c.endedAt - c.connectedAt) / 1000) : 0 });
+ } catch {}
+ clearTimeout(endTimer);
+ endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS);
+ }
+
+ // (iceCandidatePoolSize: the routes — TURN's included, which take a round
+ // trip to allocate — are gathered from the start, not from the answer on)
+ async function connection(c) {
+ const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 });
+ c.pc = pc;
+ c.remote = new MediaStream();
+ pc.ontrack = (e) => {
+ for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t);
+ // a picture that stops coming (they stopped sharing, camera and all) mutes
+ e.track.onmute = e.track.onunmute = changed;
+ changed();
+ };
+ c.trickle = env.trickle ? env.trickle() !== false : true;
+ c.sent = [];
+ pc.onicecandidate = (e) => {
+ // the routes found before the offer or answer went ride inside it;
+ // only the late ones go one by one (one signed message each — dozens
+ // of them got the household's IP rate-limited on the relays, and the
+ // answer refused with them)
+ if (!c.trickle || !c.described || !e.candidate || call !== c || c.state === 'ended') return;
+ if (c.described.includes(e.candidate.candidate)) return;
+ c.sent.push(e.candidate);
+ sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {});
+ };
+ pc.onconnectionstatechange = () => {
+ if (call !== c) return;
+ const s = pc.connectionState;
+ if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); }
+ else if (s === 'failed') end('failed', c);
+ };
+ if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local);
+ return pc;
+ }
+ // While a call rings: its connection made and the offer set, the routes
+ // gathering — everything but the microphone and camera, which wait for
+ // Answer. Answering then only adds them and replies (it used to do all of
+ // this after the tap, seconds of "Connecting…").
+ async function prepare(c) {
+ const pc = await connection(c);
+ if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; }
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ return pc;
+ }
+ // our tracks into the transceivers the offer made
+ function attachLocal(c) {
+ const trs = c.pc.getTransceivers();
+ for (const track of c.local.getTracks()) {
+ const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped);
+ if (tr) {
+ tr.sender.replaceTrack(track);
+ tr.direction = 'sendrecv';
+ try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {}
+ } else c.pc.addTrack(track, c.local);
+ }
+ }
+ // Without trickle: the description once it holds routes enough — one
+ // through the router (srflx) and one through TURN (relay) — or after a
+ // second and a half. Waiting for gathering to finish took up to 4 s (TURN
+ // over TCP and TLS is slow to give up), all of it after the tap.
+ async function described(c) {
+ const pc = c.pc;
+ const enough = () => pc.iceGatheringState === 'complete'
+ || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
+ if (!enough()) {
+ await new Promise((resolve) => {
+ const check = () => { if (enough()) resolve(); };
+ pc.addEventListener('icegatheringstatechange', check);
+ pc.addEventListener('icecandidate', check);
+ setTimeout(resolve, 1500);
+ });
+ }
+ const sdp = pc.localDescription.sdp;
+ // what it carries isn't sent again; what comes later is (trickle)
+ c.described = sdp;
+ return sdp;
+ }
+ async function addCandidates(c) {
+ const list = [...(early.get(c.peer) || []), ...c.ice];
+ early.delete(c.peer); c.ice = [];
+ for (const cand of list) { try { await c.pc.addIceCandidate(cand); } catch {} }
+ }
+
+ // ---- what the person does
+ async function start(peer, type = 'voice') {
+ if (call && call.state !== 'ended') throw new Error('busy');
+ clearTimeout(endTimer);
+ const c = call = { id: uuid(), peer, type, dir: 'out', state: 'starting', ice: [], at: Date.now(), muted: false, camOff: false, facing: 'user' };
+ changed();
+ try {
+ c.local = await env.media(type, c.facing);
+ if (call !== c) { stopMedia(c); return c; }
+ const pc = await connection(c);
+ await pc.setLocalDescription(await pc.createOffer());
+ c.state = 'offering';
+ changed();
+ await sendFor(c, CALL.OFFER, peer, await described(c), [['call-type', type]]);
+ ringTimer = setTimeout(() => { if (call === c && c.state === 'offering') { sendFor(c, CALL.HANGUP, peer, 'timeout').catch(() => {}); end('no-answer', c); } }, RING_MS);
+ } catch (e) {
+ end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
+ }
+ return c;
+ }
+ async function accept() {
+ const c = call;
+ if (!c || c.state !== 'incoming') return;
+ clearTimeout(ringTimer);
+ c.state = 'connecting';
+ changed();
+ try {
+ const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]);
+ c.local = local;
+ if (call !== c) { stopMedia(c); return; }
+ let pc = ready;
+ if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c);
+ else { // nothing made while it rang (or it failed): all of it now
+ try { pc && pc.close(); } catch {}
+ pc = await connection(c);
+ await pc.setRemoteDescription({ type: 'offer', sdp: c.offer });
+ c.remoteSet = true;
+ await addCandidates(c);
+ }
+ await pc.setLocalDescription(await pc.createAnswer());
+ const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c)));
+ await env.send(c.peer, signed);
+ // our other devices stop ringing: answered here
+ env.send(env.me(), await env.sign(callEvent(CALL.ANSWER, env.me(), c.id, ''))).catch(() => {});
+ } catch (e) {
+ sendFor(c, CALL.HANGUP, c.peer, 'error').catch(() => {});
+ end(e && e.name === 'NotAllowedError' ? 'no-permission' : 'error', c);
+ }
+ }
+ async function reject() {
+ const c = call;
+ if (!c || c.state !== 'incoming') return;
+ sendFor(c, CALL.REJECT, c.peer).catch(() => {});
+ env.sign(callEvent(CALL.REJECT, env.me(), c.id)).then((s) => env.send(env.me(), s)).catch(() => {});
+ end('declined', c);
+ }
+ function hangup() {
+ const c = call;
+ if (!c || c.state === 'ended') return;
+ if (c.state === 'incoming') { reject(); return; }
+ sendFor(c, CALL.HANGUP, c.peer).catch(() => {});
+ end('hung-up', c);
+ }
+ function setMuted(on) {
+ if (!call || !call.local) return;
+ call.muted = on;
+ for (const t of call.local.getAudioTracks()) t.enabled = !on;
+ changed();
+ }
+ function setCamOff(on) {
+ if (!call || !call.local) return;
+ call.camOff = on;
+ for (const t of call.local.getVideoTracks()) t.enabled = !on;
+ changed();
+ }
+ // A voice call turned video: our camera into the call, by a new offer
+ // (NIP-AC's renegotiate). Into the video transceiver their camera already
+ // came by if there is one, else a new one. The other side sees us at once
+ // and gets the same button for theirs.
+ async function addVideo() {
+ const c = call;
+ if (!c || c.state !== 'connected' || !c.pc || !c.local || c.local.getVideoTracks().length) return;
+ try {
+ const fresh = await env.media('video-only', c.facing);
+ const track = fresh.getVideoTracks()[0];
+ if (call !== c || c.state !== 'connected') { track.stop(); return; }
+ // a new stream object, so the preview picks the camera up
+ c.local = new MediaStream([...c.local.getTracks(), track]);
+ const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video');
+ if (tr) { await tr.sender.replaceTrack(track); tr.direction = 'sendrecv'; try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} }
+ else c.pc.addTrack(track, c.local);
+ c.type = 'video'; c.camOff = false;
+ changed();
+ await renegotiate(c);
+ } catch {}
+ }
+ async function renegotiate(c) {
+ c.renegotiating = true;
+ await c.pc.setLocalDescription(await c.pc.createOffer());
+ await sendFor(c, CALL.RENEGOTIATE, c.peer, c.pc.localDescription.sdp);
+ }
+ // Your screen in the call: in the camera's place if it's on (a swap, no new
+ // offer), else into the video slot by a renegotiation, as addVideo does.
+ // Stopping — here or from the browser's own Stop sharing — brings the
+ // camera back if it was on, else stops sending a picture.
+ async function shareScreen() {
+ const c = call;
+ if (!c || c.state !== 'connected' || !c.pc || c.sharing) return;
+ let track;
+ try { track = (await env.media('screen')).getVideoTracks()[0]; } catch { return; }
+ if (!track) return;
+ if (call !== c || c.state !== 'connected') { track.stop(); return; }
+ try { track.contentHint = 'detail'; } catch {}
+ const cam = c.local.getVideoTracks()[0] || null;
+ Object.assign(c, { sharing: true, screen: track, cam });
+ c.local = new MediaStream([...c.local.getAudioTracks(), track]);
+ let renego = false;
+ const sender = c.pc.getSenders().find((x) => x.track && x.track.kind === 'video');
+ if (sender) await sender.replaceTrack(track);
+ else {
+ const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video');
+ if (tr) { await tr.sender.replaceTrack(track); if (tr.direction !== 'sendrecv') { tr.direction = 'sendrecv'; renego = true; } }
+ else { c.pc.addTrack(track, c.local); renego = true; }
+ }
+ c.type = 'video';
+ track.onended = () => { if (call === c && c.screen === track) stopShare(); };
+ changed();
+ if (renego) await renegotiate(c).catch(() => {});
+ }
+ async function stopShare() {
+ const c = call;
+ if (!c || !c.sharing) return;
+ const track = c.screen, cam = c.cam;
+ Object.assign(c, { sharing: false, screen: null, cam: null });
+ try { track.stop(); } catch {}
+ if (!c.pc || c.state === 'ended') return;
+ const sender = c.pc.getSenders().find((x) => x.track === track);
+ if (cam && cam.readyState === 'live') {
+ if (sender) await sender.replaceTrack(cam).catch(() => {});
+ c.local = new MediaStream([...c.local.getAudioTracks(), cam]);
+ changed();
+ return;
+ }
+ c.local = new MediaStream(c.local.getAudioTracks());
+ changed();
+ if (sender) {
+ await sender.replaceTrack(null).catch(() => {});
+ const tr = c.pc.getTransceivers().find((x) => x.sender === sender);
+ if (tr && !tr.stopped) { tr.direction = 'recvonly'; await renegotiate(c).catch(() => {}); }
+ }
+ }
+ // the other camera: the sender's track swapped, no new offer needed
+ async function flip() {
+ const c = call;
+ if (!c || !c.local || !c.local.getVideoTracks().length) return;
+ const facing = c.facing === 'user' ? 'environment' : 'user';
+ try {
+ const fresh = await env.media('video-only', facing);
+ const track = fresh.getVideoTracks()[0];
+ const sender = c.pc && c.pc.getSenders().find((s) => s.track && s.track.kind === 'video');
+ if (sender) await sender.replaceTrack(track);
+ for (const old of c.local.getVideoTracks()) { c.local.removeTrack(old); old.stop(); }
+ track.enabled = !c.camOff;
+ c.local.addTrack(track);
+ c.facing = facing;
+ changed();
+ } catch {}
+ }
+
+ // ---- what arrives
+ // `maxAge`: an offer the service worker kept from a push may be older than
+ // a relay's live one (the person had to tap the notification): it still
+ // rings, for what's left of its minute
+ async function handle(ev, { maxAge = STALE_S } = {}) {
+ if (!ev || seen.has(ev.id)) return;
+ if (now() - ev.created_at > maxAge) return;
+ seen.add(ev.id);
+ if (seen.size > 5000) seen.clear();
+ const me = env.me();
+ const id = callIdOf(ev), from = ev.pubkey, self = from === me;
+ const c = call;
+ switch (ev.kind) {
+ case CALL.OFFER: {
+ if (self) return;
+ if (c && c.state !== 'ended') {
+ if (c.id !== id) { const s = await env.sign(callEvent(CALL.REJECT, from, id, 'busy')); env.send(from, s).catch(() => {}); }
+ return;
+ }
+ const may = await env.mayRing(from);
+ if (!may) return;
+ if (may === 'quiet') {
+ // not someone who rings us: no ring, but not nothing either — it's
+ // logged as a call that didn't ring
+ try { if (env.ended) env.ended({ id, peer: from, type: tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice', dir: 'in', reason: 'unrung', connected: false, seconds: 0, at: ev.created_at * 1000 }); } catch {}
+ return;
+ }
+ clearTimeout(endTimer);
+ const type = tagOf(ev, 'call-type') === 'video' ? 'video' : 'voice';
+ const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' };
+ ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000)));
+ changed();
+ try { if (env.warm) env.warm(from); } catch {}
+ n.prep = prepare(n).catch(() => null);
+ return;
+ }
+ case CALL.ANSWER: {
+ if (!c || c.id !== id) return;
+ if (self) { if (c.state === 'incoming') end('answered-elsewhere', c); return; } // another of our devices took it
+ if (from !== c.peer) return;
+ if (c.state === 'offering') {
+ c.state = 'connecting'; changed();
+ try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); c.remoteSet = true; await addCandidates(c); }
+ catch { end('error', c); return; }
+ // Our candidates went out while they rang — to an app that may have
+ // been closed then (woken by a push, it heard none of them). Once
+ // more, now that they're listening; a repeat is harmless.
+ if (c.trickle) for (const cand of c.sent || []) sendFor(c, CALL.ICE, c.peer, candidateJson(cand)).catch(() => {});
+ } else if (c.renegotiating) { // their answer to our renegotiation
+ c.renegotiating = false;
+ try { await c.pc.setRemoteDescription({ type: 'answer', sdp: ev.content }); } catch {}
+ }
+ return;
+ }
+ case CALL.ICE: {
+ if (self) return;
+ let cand; try { cand = JSON.parse(ev.content); } catch { return; }
+ if (c && c.id === id && from === c.peer) {
+ if (c.pc && c.remoteSet) { try { await c.pc.addIceCandidate(cand); } catch {} }
+ else c.ice.push(cand);
+ } else {
+ // before the offer that opens its call: kept for when it does
+ const list = early.get(from) || []; list.push(cand); early.set(from, list.slice(-50));
+ }
+ return;
+ }
+ case CALL.HANGUP: {
+ if (self || !c || c.id !== id || from !== c.peer) return;
+ end(c.state === 'incoming' ? 'missed' : 'peer-hung-up', c);
+ return;
+ }
+ case CALL.REJECT: {
+ if (!c || c.id !== id) return;
+ if (self) { if (c.state === 'incoming') end('declined-elsewhere', c); return; }
+ if (from !== c.peer) return;
+ end(ev.content === 'busy' ? 'busy' : 'peer-declined', c);
+ return;
+ }
+ case CALL.RENEGOTIATE: {
+ if (self || !c || c.id !== id || from !== c.peer || !c.pc) return;
+ try {
+ if (c.pc.signalingState === 'have-local-offer') {
+ if (me > from) return; // ours wins (the higher key), theirs is dropped
+ await c.pc.setLocalDescription({ type: 'rollback' });
+ }
+ await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content });
+ await c.pc.setLocalDescription(await c.pc.createAnswer());
+ await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp);
+ // they turned their camera on: this is a video call now
+ if (c.type !== 'video' && /^m=video [1-9]/m.test(ev.content)) { c.type = 'video'; changed(); }
+ } catch {}
+ return;
+ }
+ }
+ }
+
+ return {
+ get call() { return call; },
+ start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, shareScreen, stopShare, handle,
+ // a sign-out or account switch: whatever was going on ends here
+ stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); },
+ };
+}
diff --git a/src/cordn-client.js b/src/cordn-client.js
index d09488d..e0be08b 100644
--- a/src/cordn-client.js
+++ b/src/cordn-client.js
@@ -67,14 +67,16 @@ export function cordnClient({ scope, pubkey, identity, on, coordinator = C.DEFAU
// never used unasked.
if (id.signer.interactive && !interactive) return null;
if (!id.signer.decryptFrom) return null;
- return { raw: null, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) };
+ return { raw: null, obj: id.signer, sign: (e) => id.signer.signEvent(e), decrypt: (peer, ct) => id.signer.decryptFrom(peer, ct) };
}
// The transport, once a signer can back it. Rebuilt if the signer changes.
async function transport(opts) {
const s = await signer(opts);
if (!s) return null;
- const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : 'adapter';
+ // a remote signer that reconnected is a new object: the old one is
+ // closed ("not open anymore"), so the transport must follow it
+ const key = s.raw ? Array.from(s.raw.slice(0, 8)).join(',') : s.obj;
if (cvm && cvmSigner === key) return cvm;
if (cvm) cvm.stop();
cvmSigner = key;
diff --git a/src/dm-inbox.js b/src/dm-inbox.js
index b9eec3a..58fc72d 100644
--- a/src/dm-inbox.js
+++ b/src/dm-inbox.js
@@ -15,6 +15,8 @@
// notification instead of a filtered one.
import { unwrapDM } from './dm.js';
+import { openCall, CALL, RING_S } from './calls.js';
+import * as nip44 from 'nostr-tools/nip44';
const DB = 'coinos-dm-inbox';
const STORE = 'inbox';
@@ -115,3 +117,28 @@ export function shouldNotifyDm(verdict) {
if (verdict.followed || verdict.known) return true;
return !verdict.hasList;
}
+
+// A call ringing in (NIP-AC), the wrap a push pointed at: opened with the key
+// it's for, and worth ringing only as a fresh offer from someone this
+// account follows or talks to, and hasn't muted. Returns { offer, name,
+// type } to ring with, or null for anything else (a candidate, a stranger,
+// a stale one, a wrap we hold no key for); `quiet` for a caller who
+// may not ring us (not followed, not written to).
+export async function classifyCall(wrap, inboxes, nowS = Math.floor(Date.now() / 1000)) {
+ for (const rec of inboxes) {
+ if (!rec || !rec.sk) continue;
+ let sk;
+ try { sk = unhex(rec.sk); } catch { continue; }
+ const ev = await openCall(wrap, async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, pk)));
+ if (!ev) continue;
+ const from = ev.pubkey;
+ if (ev.kind !== CALL.OFFER || from === rec.pubkey || nowS - ev.created_at > RING_S) return null;
+ if ((rec.muted || []).includes(from)) return null;
+ const type = (ev.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ // someone not followed nor written to doesn't ring — `quiet`: the app
+ // lists it as a call that didn't ring, next time it opens
+ const quiet = !(rec.follows || []).includes(from) && !(rec.rings || rec.known || []).includes(from);
+ return { offer: ev, name: (rec.names || {})[from] || null, type, quiet };
+ }
+ return null;
+}
diff --git a/src/features/ark.js b/src/features/ark.js
index 13481da..8b3121c 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1,12 +1,14 @@
+import { momentTags } from '../media-timeline.js';
// Ark feature — off-chain payments via an ASP (Second's bark/captaind
// protocol, spoken natively by ../ark). Receive address + boarding, instant
// sends, history entries, refresh/consolidation, server settings.
+import { zapAddrOf } from '../live.js';
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, sameDelivery, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -16,6 +18,17 @@ function freeCoins(st) {
const busy = new Set(remoteRenewalsInFlight(st).flatMap((r) => r.inputIds || []));
return (st.vtxos || []).filter((v) => v.state === 'spendable' && !busy.has(v.id));
}
+// The coins a unilateral exit can still take on-chain. Past its timelock a
+// coin's tree is the server's to sweep (and it has): only the server can
+// honor it now, and it does — but no exit transaction will ever confirm.
+function exitableCoins(st, tip) {
+ return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip));
+}
+// The crash-loop guard's phase notes (build.js BOOT_GUARD, app.js bootPhase):
+// a boot iOS killed shows the last one it reached, and three in a row open
+// the app in safe mode, where nothing connects on its own.
+const bootPhase = (p) => { try { localStorage.setItem('coinos-boot-phase', p); } catch {} };
+const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot);
import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js';
import { boardFee, p2trAddress } from '../ark/board.js';
import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js';
@@ -30,7 +43,7 @@ import { t } from '../i18n.js';
import { rateNow } from '../rates.js';
import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js';
import { parseZapTarget, fetchPayParams, requestInvoice } from '../lnurl.js';
-import { shortAddr, shortTxid, timeAgo, fmtSats, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js';
+import { shortAddr, shortTxid, timeAgo, fmtSats, fmtBtc, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js';
// t?ark1… bech32m — an Ark address for this or another ASP.
export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); }
@@ -343,6 +356,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;
@@ -450,8 +465,41 @@ export function arkFeature(ctx) {
// One manager per open wallet; null when Ark is off in Settings, watch-only,
// or not yet dialed (lazy: fresh wallets connect on first use).
let ark = null;
+ // The same manager the moment its own init is done (server info, tip,
+ // persisted coins) — before the Nostr snapshot restore that `ark` also
+ // waits for, which can take ten seconds on a slow relay/Blossom day. For
+ // READING only: the pages price and label coins from it; anything that
+ // acts (a renewal, an exit) still waits for `ark`.
+ let arkInited = null;
+ const arkView = () => ark || arkInited;
+ // An amount in bitcoin terms whatever the display unit: sats, or BTC when
+ // that is the unit chosen — never fiat. A co-operative exit moves coins,
+ // and the number on its button is the coins' own.
+ const fmtNative = (sat) => (unitLabel() === 'BTC' ? fmtBtc(sat) + ' BTC' : fmtSats(sat) + ' sats');
+ // The last server info and chain tip seen, per server, so the first paint
+ // after a refresh shows the real renewal price and expiries instead of
+ // "free" and "—" for the half second the info call takes.
+ const ARK_INFO_KEY = 'btc-wallet-arkinfo:';
+ function cachedArkInfo(url) {
+ try { return url ? JSON.parse(localStorage.getItem(ARK_INFO_KEY + url) || 'null') : null; } catch { return null; }
+ }
+ function rememberArkInfo(url, info, tip) {
+ try {
+ if (!url || !info) return;
+ // no tip yet (right after init): keep the last one seen, dated as it was
+ const prev = tip ? null : cachedArkInfo(url);
+ localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() }));
+ } catch {}
+ }
+ // the tip, live or estimated from the last one seen (a block per ten minutes)
+ function arkTipNow(mgr) {
+ if (mgr && mgr._tipH) return mgr._tipH;
+ const c = cachedArkInfo((getArkConfig() || {}).ark);
+ return c && c.tip ? c.tip + Math.floor((Date.now() - (c.at || Date.now())) / 600_000) : 0;
+ }
let arkTimer = null;
let arkConnectPromise = null;
+ let arkInitPromise = null; // the manager's own init, apart from the snapshot gate
let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch
function stopArk() {
@@ -470,7 +518,9 @@ export function arkFeature(ctx) {
clearTimeout(lnTagT);
lnTagSeen = -1;
ark = null;
+ arkInited = null;
arkConnectPromise = null;
+ arkInitPromise = null;
// The renewal warning describes the coins of the wallet we're leaving.
// Switching accounts used to carry it onto the next one — with the old
// wallet's amount and deadline, over the new wallet's balance — and the
@@ -502,9 +552,11 @@ export function arkFeature(ctx) {
// holds is on screen the moment it can be.
const store = wallet._arkStore;
if (store && !store.ready) {
+ bootPhase('ark-store');
store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {});
return;
}
+ if (safeBoot()) return; // safe mode: the coins show from the store; nothing dials out unasked
if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().then(() => rescueOtherServers()).catch(() => {}); }
}
@@ -537,7 +589,7 @@ export function arkFeature(ctx) {
adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, p.ark),
save: (s) => { wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(p.ark)), p.ark); },
},
- arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(),
+ arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), honorsExpired: !!p.honorsExpired,
});
await mgr.init();
const done = () => (mgr.state.movements || []).filter((m) => m.type === 'ln-receive' && m.status === 'complete');
@@ -551,7 +603,7 @@ export function arkFeature(ctx) {
}
}
function maybeInitArk() {
- if (ark || arkConnectPromise) return;
+ if (ark || arkConnectPromise || safeBoot()) return;
if (Date.now() - lastAutoInit < 20000) return;
// A fresh snapshot may be held under its server pubkey, so arkWanted()
// cannot see it until the handshake identifies the server and adopts it.
@@ -594,6 +646,7 @@ export function arkFeature(ctx) {
arkPersistFlushers.add(flushPersist);
const mgr = new ArkManager({
account: wallet.account(),
+ lnOldestFirst: () => getCustody() !== 'custodial',
storage: {
load: () => wallet.loadArkState(cfg.ark),
adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, cfg.ark),
@@ -619,6 +672,10 @@ export function arkFeature(ctx) {
arkUrl: cfg.ark,
esploraUrl: cfg.esplora,
network: getNetwork(),
+ honorsExpired: !!cfg.honorsExpired,
+ // the server's last published info: the wallet opens on it when the
+ // server is gone, so coins can still be read and exited
+ infoHint: (cachedArkInfo(cfg.ark) || {}).info || null,
// the wallet's own tx table as a second witness for confirmations —
// its data source is user-configurable and often healthier than the
// ark preset's esplora from this device
@@ -632,9 +689,27 @@ export function arkFeature(ctx) {
// Open the server connection while Nostr restores, but do not replay the
// mailbox before the snapshot's history and cursor have arrived.
const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false);
- arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => {
+ bootPhase('ark-init');
+ const inited = mgr.init().then(() => {
+ if (gen !== arkInitGen) return;
+ bootPhase('ark-inited');
+ arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot
+ if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
+ render();
+ });
+ arkInitPromise = inited;
+ // The tip: expiries and exit costs are judged against it, and init alone
+ // never asks for it (the first sync does, after the snapshot gate). Ask
+ // now, off the connect path, and remember it for the next refresh.
+ inited.then(() => mgr._tipMemo()).then(() => {
+ if (gen !== arkInitGen || !mgr._tipH) return;
+ rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
+ render();
+ }).catch(() => {});
+ arkConnectPromise = Promise.all([inited, restored]).then(() => {
if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect
ark = mgr;
+ bootPhase('ark-adopt');
// Sync may have arrived while init awaited the server handshake.
adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark));
// Boards that died before funding (a failed tx build, a closed tab)
@@ -675,12 +750,14 @@ export function arkFeature(ctx) {
// sync() already reconciles on its first pass. Start the live stream
// afterwards, at the caught-up cursor: running both together replayed
// the backlog one message/render at a time alongside the batch read.
+ bootPhase('ark-sync');
tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); });
mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed
// Receives arrive in real time over the mailbox stream; the poll is the
// fallback and what drives in-flight boards/refreshes forward.
scheduleLnTag(mgr); // receives that landed while this device was closed
arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000);
+ bootPhase('ark-ready');
render();
return mgr;
}).catch((e) => {
@@ -919,6 +996,26 @@ export function arkFeature(ctx) {
// connections/offer belong to the nwc feature; when it isn't the caller
// (an auto-withdraw setting changed, say) carry forward what's mirrored
// rather than blanking someone's wallet connections.
+ // The mirror carries hundreds of pre-derived keys (BG_KEY_LOOKAHEAD) and
+ // is rewritten after every state change: derive each index once per
+ // account, not once per write.
+ const bgKeyMemo = new WeakMap(); // account node -> Map('chain:i' -> hex)
+ function bgKeyFor(account) {
+ let m = bgKeyMemo.get(account);
+ if (!m) { m = new Map(); bgKeyMemo.set(account, m); }
+ const chains = new Map();
+ return (chain, i) => {
+ const k = chain + ':' + i;
+ let v = m.get(k);
+ if (!v) {
+ let node = chains.get(chain);
+ if (!node) { node = account.deriveChild(chain); chains.set(chain, node); }
+ v = hex.encode(node.deriveChild(i).privateKey);
+ m.set(k, v);
+ }
+ return v;
+ };
+ }
async function writeBgMirror(connections, offer, prefs) {
const cfg = getArkConfig();
if (!cfg || !ark || !ark.info || !ark.state) return;
@@ -927,14 +1024,15 @@ export function arkFeature(ctx) {
if (offer === undefined) offer = (prev && prev.offer) || null;
if (prefs === undefined) prefs = (prev && prev.prefs) || undefined;
const rec = buildBg({
- ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey },
+ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired },
mgrState: ark.state,
- keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey),
+ custody: getCustody(),
+ keyFor: bgKeyFor(ark.account),
connections,
offer,
autowithdraw: await awMirror(prev),
});
- if (prev) rec.spends = prev.spends; // the worker's log survives rewrites
+ if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites
if (prefs) rec.prefs = prefs; // notification choices the worker honours
await saveBg(wallet._cacheKey(), rec);
await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {});
@@ -953,6 +1051,7 @@ export function arkFeature(ctx) {
return;
}
if (!rec.mgr) return;
+ bootPhase('ark-merge');
const merged = mergeArkStates(mgr.state, rec.mgr);
// the worker's view of OUR vtxos is newer for anything it spent
const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state]));
@@ -962,8 +1061,11 @@ export function arkFeature(ctx) {
merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1);
mgr.state = merged;
mgr._save();
+ bootPhase('ark-merged');
await mgr.reconcile().catch(() => {});
+ bootPhase('ark-reconciled');
await mgr.resumePending().catch(() => {});
+ bootPhase('ark-resumed');
}
// A leftover chain-6 pouch from before whole-balance background spending:
@@ -983,7 +1085,7 @@ export function arkFeature(ctx) {
const pm = await new ArkManager({
account: shim,
storage: { load: () => state, save: (s2) => { state = s2; } },
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
+ arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired,
}).init();
const dest = mgr.address();
for (let guard = 0; guard < 30; guard++) {
@@ -1418,15 +1520,37 @@ export function arkFeature(ctx) {
h('span', { class: g.claimed ? 'tag conf' : 'tag' },
g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag')));
})(),
- (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(),
- m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null),
- h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
+ (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })()),
+ // the status tag sits by the time, not the name: in the narrow wallet
+ // column the title row has room for the label and the chip, not a
+ // third thing — the chip was breaking mid-name to make space
+ h('div', { class: 'row gap6 small faint' }, timeAgo(m.ts / 1000),
+ m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null)),
h('div', { style: 'text-align:right' },
// in fiat this is what the payment was worth when it was made
h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, fmtAmount(m.amountSat, m.ts / 1000)))
);
}
+ // a movement as a line of the history export (Settings → Wallet): what
+ // was paid, the fee beside it, and the change to the balance — a failed
+ // payment moved nothing; a renewal moved nothing but its fee
+ function arkCsvRow(m) {
+ if (m.status === 'failed') return null;
+ const out = ['send', 'offboard', 'exit', 'ln-send', 'refresh', 'reconcile'].includes(m.type);
+ const fee = m.feeSat != null ? m.feeSat : Number(((m.detail || '').match(/fee (\d+) sat/) || [])[1]) || 0;
+ const amount = m.type === 'refresh' ? 0 : m.amountSat || 0;
+ const viaLn = m.type.startsWith('ln-') || m.viaLn;
+ return {
+ ts: m.ts, type: m.type === 'reconcile' ? 'spent-elsewhere' : m.type,
+ rail: viaLn ? 'lightning' : ONCHAIN_ARK.has(m.type) ? 'onchain' : 'ark',
+ amount: out ? -amount : amount, fee: out ? fee : 0, net: out ? -(amount + fee) : amount,
+ status: m.status === 'complete' ? 'complete' : 'pending',
+ ref: m.txid || m.paymentHash || m.vtxoId || '', to: m.to || '', invoice: m.invoice || '',
+ note: m.type === 'reconcile' ? t('arkDetailsNotSynced') : '',
+ };
+ }
+
const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now();
// A Lightning payment (a zap, mostly) still settling: the money has left
// the balance, so history shows it now as the Sent row it will become.
@@ -1570,6 +1694,7 @@ export function arkFeature(ctx) {
})(),
m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null,
m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null,
+ m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null,
// the coins this payment consumed — a send can gather up to 24 inputs
coinListRows(t('arkVtxoInputs'), m.inputIds),
// An offboard's fee is two different pockets — the miners' cut for the
@@ -1618,7 +1743,7 @@ export function arkFeature(ctx) {
const g = giftForMovement(m);
const open = g && !g.claimed && !g.revoked;
const chips = [
- open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}`, t('giftCopyLinkAgain')) : null,
+ open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}${g.note ? '#m=' + encodeURIComponent(g.note) : ''}`, t('giftCopyLinkAgain')) : null,
m.preimage ? copyBtn(m.preimage, t('copyPreimage')) : null,
m.txid ? copyBtn(m.txid, t('copyTxid')) : null,
m.txid ? h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')) : null,
@@ -1709,8 +1834,8 @@ export function arkFeature(ctx) {
// 2. lightning: profile lud16/lud06, via the zap flow
// 3. an on-chain address from the BIP-353 record
const NAMES_REGISTRAR = 'https://names.coinos.io';
- function startNpubPay(pk, npub, eventId = null, autoSat = 0) {
- const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat };
+ function startNpubPay(pk, npub, eventId = null, autoSat = 0, moment = null) {
+ const z = { npub, pk, eventId, moment, amount: '', comment: '', status: 'lookup', autoSat };
// A one-tap post zap has no card: it resolves and pays off-screen, so a
// second tap — the same post again, or another — never evicts the one
// already in the air. Everything else takes the send screen's zap slot.
@@ -1792,7 +1917,7 @@ export function arkFeature(ctx) {
z.lnOk = true;
await connectArk().catch(() => {});
if (!live()) return;
- if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
+ if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat, z.moment); return; }
}
// 3. on-chain fallback (a one-tap zap can't take the send form)
if (onchain && !(z.autoSat && z.eventId)) {
@@ -1912,7 +2037,7 @@ export function arkFeature(ctx) {
await wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['vtxo', vtxoId], ['network', getNetwork()], ...zapSenderTag()],
}).catch(() => {});
}
@@ -1955,7 +2080,7 @@ export function arkFeature(ctx) {
wallet.nostrPublish({
kind: ARK_ZAP_KIND,
content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []),
+ tags: [...momentTags(z.moment), ['p', z.pk], ...(z.eventId ? [['e', z.eventId]] : []), ...(zapAddrOf(z.eventId) ? [['a', zapAddrOf(z.eventId)]] : []),
['amount', String(sats)], ['network', getNetwork()], ['gift', locked.url], ...zapSenderTag()],
}).catch(() => {});
const dmText = t('giftDmText', { amount: fmtAmount(sats) + ' ' + unitLabel(), link: locked.url, code: locked.claimCode });
@@ -2043,7 +2168,7 @@ export function arkFeature(ctx) {
// Only someone with a Lightning address gets the Lightning door (the
// lookup normally hands such a person straight to the zaps feature).
noAddr && z.lnOk && ctx.hook('canLnZap')
- ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
+ ? h('button', { class: 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId, 0, z.moment); } }, '⚡ ' + t('lnZapFallback'))
: null,
payable && spendable < 330
? h('div', { class: 'notice info' }, t(spendable + renewingSat() >= 330 ? 'spendingRenewingPayment' : 'zapNoBalance'))
@@ -2124,23 +2249,8 @@ export function arkFeature(ctx) {
const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address));
// Offboarding moves whole vtxos, so carve out the exact amount first
// when we're not sending everything.
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
- let ids;
- if (amountSat < total) {
- let exact = spendables().find((v) => v.amountSat === amountSat);
- if (!exact) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !exact; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!exact) throw new Error(t('arkSplitTimeout'));
- }
- ids = [exact.id];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ const ids = amountSat < total ? await carveCoins(mgr, amountSat) : undefined;
await mgr.startOffboard(spk, target.address, ids);
return;
}
@@ -2278,7 +2388,7 @@ export function arkFeature(ctx) {
// page, or a tap before the manager has connected (the first seconds
// after boot) found no coins and closed itself — Manage needed two taps.
// The manager is dialled meanwhile; what needs it live waits for it.
- const mgr = ark;
+ const mgr = arkView();
const st = (mgr && mgr.state) || arkStateNow();
// another device's renewal holds its coins just the same: greyed here too
const elsewhere = st ? remoteRenewalsInFlight(st) : [];
@@ -2290,7 +2400,11 @@ export function arkFeature(ctx) {
const renewing = ((st && st.actions) || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step));
const inRound = new Set([...renewing, ...elsewhere].flatMap((a) => a.inputIds || []));
const pendingCoins = ((st && st.vtxos) || []).filter((v) => (v.state === 'pending' || v.state === 'spendable') && inRound.has(v.id));
- const tip = (mgr && mgr._tipH) || 0;
+ const tip = arkTipNow(mgr);
+ // the coinos servers keep honoring a coin past its timelock: it is held
+ // by coinos, not expired — still money, just not exitable on its own
+ const honors = !!(mgr && mgr.honorsExpired);
+ const exitable = exitableCoins(st, tip);
const back = embedded
? () => { ui.arkCoinsSel = null; ui.arkRenewConfirm = null; ctx.goHome(); }
: () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkRenewConfirm = null; }); // the page is a history entry now
@@ -2312,7 +2426,7 @@ export function arkFeature(ctx) {
const expiresOf = (v) => {
if (!v.expiryHeight || !tip) return '—';
const blocks = v.expiryHeight - tip;
- if (blocks <= 0) return t('arkCoinsExpired');
+ if (blocks <= 0) return honors ? t('arkCoinsHeld') : t('arkCoinsExpired');
return blocks >= 144 ? Math.round(blocks / 144) + 'd' : Math.max(1, Math.round(blocks / 6)) + 'h';
};
// what pulling THIS coin on-chain alone would cost: every hop's fee
@@ -2330,10 +2444,11 @@ export function arkFeature(ctx) {
// bracket the share is zero, where the wallet renews on its own anyway.
// A flat base fee (the round's on-chain cost, which coinos stopped
// absorbing in October 2026) rides on top of every bracket.
- const table = (((mgr && mgr.info) || {}).refreshFees || {}).ppmExpiryTable || [];
- const baseSat = (((mgr && mgr.info) || {}).refreshFees || {}).baseFeeSat || 0;
+ const infoNow = (mgr && mgr.info) || (cachedArkInfo((getArkConfig() || {}).ark) || {}).info || {};
+ const table = (infoNow.refreshFees || {}).ppmExpiryTable || [];
+ const baseSat = (infoNow.refreshFees || {}).baseFeeSat || 0;
const baseCost = fmtSats(baseSat) + ' sats';
- const roundMins = Math.max(1, Math.round((((mgr && mgr.info) || {}).roundIntervalSecs || 120) / 60));
+ const roundMins = Math.max(1, Math.round((infoNow.roundIntervalSecs || 120) / 60));
const roundEvery = roundMins === 1 ? t('arkCoinsRoundEveryOne') : t('arkCoinsRoundEvery', { n: roundMins });
const ppmFor = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
// A coin's SHARE of the renewal fee, fractional: the server totals the
@@ -2371,6 +2486,12 @@ export function arkFeature(ctx) {
for (const id of ui.arkCoinsSel) if (!ids.has(id)) ui.arkCoinsSel.delete(id);
const sel = ui.arkCoinsSel;
const selCoins = spend.filter((v) => sel.has(v.id));
+ // the three actions below the table act on the ticked coins
+ const selExitable = selCoins.filter((v) => exitable.includes(v));
+ const selExitSat = selExitable.reduce((n, v) => n + v.amountSat, 0);
+ let selExitFee = 0; try { if (mgr) selExitFee = estimateExitFeeSat(mgr, selExitable); } catch {}
+ const subset = selCoins.length > 0 && selCoins.length < spend.length;
+ const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat;
let selFee = 0; try { selFee = mgr.refreshFee(selCoins, tip); } catch {}
const renew = () => {
if (!selCoins.length || !mgr) return;
@@ -2381,6 +2502,20 @@ export function arkFeature(ctx) {
ui.arkRenewConfirm = null;
render(); // the page stays: the coins show as renewing
};
+ // What a renewal is and what it costs, told where it is decided: on the
+ // confirmation, not as a card on the page (the page is the coins and
+ // their three buttons; the explainers were a wall under them).
+ const renewExplainer = () => [
+ h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors
+ ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') })
+ : baseSat
+ ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost })
+ : t('arkCoinsRenewDesc', { every: roundEvery })),
+ schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' },
+ ...schedule.map((r) => h('div', { class: 'row between' },
+ h('span', { class: 'small muted' }, r.label),
+ h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null,
+ ];
// "Renew" asks once, with the numbers on the table: the coins it takes
// can't be spent until the round's transaction confirms, it can't be
// called back, and by default it takes every coin. (A user renewed their
@@ -2393,6 +2528,7 @@ export function arkFeature(ctx) {
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkRenewConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmBody')),
+ ...renewExplainer(),
h('div', { class: 'col', style: 'gap:4px' },
row(t('arkExitConfirmCoins'), String(selCoins.length)),
row(t('arkExitConfirmAmount'), fmtSats(selSat) + ' sats'),
@@ -2400,7 +2536,7 @@ export function arkFeature(ctx) {
row(t('arkRenewConfirmLeft'), fmtSats(leftSat) + ' sats', leftSat ? '' : 'err')),
leftSat ? null : h('div', { class: 'notice err small' }, t('arkRenewConfirmAll')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmWait')),
- h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !mgr, onClick: renew },
+ h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !ark, onClick: renew },
selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkRenewConfirm = null; render(); } }, t('cancel')));
}
@@ -2433,22 +2569,23 @@ export function arkFeature(ctx) {
// is published: an exit cannot be called back.
if (ui.arkExitConfirm) {
const savings = wallet.spendable || 0;
- const short = savings < exitFee;
+ const short = savings < selExitFee;
const row = (k, v, cls = '') => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' + (cls ? ' ' + cls : '') }, v));
return h('div', { class: 'col', style: 'gap:16px' },
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(selExitFee), after: fmtSats(afterFee) })),
h('div', { class: 'col', style: 'gap:4px' },
- row(t('arkExitConfirmCoins'), String(spend.length)),
- row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' sats'),
- row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'),
+ row(t('arkExitConfirmCoins'), String(selExitable.length)),
+ row(t('arkExitConfirmAmount'), fmtSats(selExitSat) + ' sats'),
+ row(t('arkExitConfirmFee'), fmtSats(selExitFee) + ' sats'),
row(t('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')),
- short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), have: fmtSats(savings) })) : null,
+ short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(selExitFee), have: fmtSats(savings) })) : null,
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmWait')),
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !spend.length, onClick: () => {
+ h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !selExitable.length, onClick: () => {
ui.arkExitConfirm = null;
- doArkExit();
+ doArkExit(selExitable.map((v) => v.id));
ui.arkCoinsSel = null;
back();
ui.tab = 'history'; // the exit lives as a pending record there
@@ -2499,7 +2636,8 @@ export function arkFeature(ctx) {
h('tbody', {}, ...spend.map((v) => {
const f = feeNowOf(v);
const xf = exitFeeOf(v);
- const due = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0) || !!v.expiryRejected;
+ const past = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0);
+ const due = (past && !honors) || !!v.expiryRejected;
// expired and under the floor: nothing can renew it, so the
// last column offers to give it up
const lost = due && v.amountSat < 330;
@@ -2522,43 +2660,78 @@ export function arkFeature(ctx) {
h('td', { class: 'num' }, fmtSats(v.amountSat), h('span', { class: 'small faint' }, ' sats')),
h('td', { class: 'num' }, expiresOf(v)),
h('td', { class: 'num' }, '—'),
- h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing'))))))))),
+ h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing')))))))),
+ // What the ticked coins can do, right under them: renew, withdraw
+ // (the co-operative exit, into Savings) or exit on their own. Each
+ // names the ticked total it acts on.
+ // present even while every coin is in a round: disabled, so the page
+ // keeps its shape and says why nothing can be done
+ h('div', { class: 'col ark-coin-actions', style: 'gap:6px;margin-top:4px' },
+ h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } },
+ !selCoins.length ? t('arkCoinsRenewNone')
+ : selCoins.length < spend.length
+ ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
+ : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn')),
+ h('button', { class: 'btn-ghost btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || !selCoins.length || wallet.watchOnly, onClick: () => {
+ const address = wallet.freshReceive().address;
+ const vtxoIds = subset ? selCoins.map((v) => v.id) : undefined;
+ ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null;
+ ui.arkOffboarded = null; ui.arkError = '';
+ ctx.showSend({ fresh: true });
+ ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds };
+ render();
+ } }, t('arkCoinsWithdrawBtn', { amount: fmtNative(coopSat) })),
+ h('button', { class: 'btn-ghost btn-block ark-exit-ask', disabled: !!ui.arkBusy || !selExitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } },
+ t('arkCoinsExitBtnAmt', { amount: fmtNative(selExitSat) })))),
renewingCard,
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')),
- // the round cadence comes from the server (it has been an hour and
- // is two minutes as of September 2026) — never a number baked in here
- h('p', { class: 'small muted', style: 'margin:0' }, baseSat
- ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost })
- : t('arkCoinsRenewDesc', { every: roundEvery })),
- schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' },
- ...schedule.map((r) => h('div', { class: 'row between' },
- h('span', { class: 'small muted' }, r.label),
- h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null,
- !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !mgr, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } },
- !selCoins.length ? t('arkCoinsRenewNone')
- : selCoins.length < spend.length
- ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
- : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !spendableSat || wallet.watchOnly, onClick: () => {
- const address = wallet.freshReceive().address;
- ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null;
- ui.arkOffboarded = null; ui.arkError = '';
- ctx.showSend({ fresh: true });
- ui.arkOffboardSend = { address, amount: String(spendableSat), fromCoins: true };
- render();
- } }, t('arkOffboardBtn', { n: fmtAmount(spendableSat) + ' ' + unitLabel() }))),
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')),
- h('p', { class: 'small muted', style: 'margin:0' },
- t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })),
- h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !spend.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))),
+ embedded ? custodyCard() : null,
embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
}
+ // Who holds the coins once their timelock runs out — the user's call, made
+ // here, with what each option costs in the server's own numbers. The
+ // default is coinos: most people come from v2, where coinos held
+ // everything, and the server honors a coin past its timelock anyway. The
+ // two renewing tiers are the opt-ins, for those who want the exit kept
+ // (self) or kept cheap by flattening deep histories early (exit). Only
+ // shown on a server that honors expired coins; elsewhere renewal is the
+ // only way to keep money.
+ function custodyCard() {
+ if (!arkAvailable() || wallet.watchOnly) return null;
+ const cfg = getArkConfig();
+ const view = arkView();
+ const info = (view && view.info) || (cachedArkInfo(cfg && cfg.ark) || {}).info || null;
+ const honors = !!(cfg && cfg.honorsExpired);
+ const cur = getCustody(); // never 'custodial' on a server that doesn't honor expired coins
+ const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0;
+ const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || [];
+ const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
+ const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144));
+ const baseCost = !info ? '…' : base ? fmtSats(base) + ' sats' : t('arkDepthFree');
+ // an early renewal pays the bracket a fresh coin sits in (the top one)
+ const earlyPct = pct(ppmAt(((info && info.vtxoExpiryDelta) || 8640) - 1));
+ const hops = (renewPolicy('exit', getNetwork()) || {}).maxDepth || 4;
+ const tiers = [
+ { id: 'custodial', label: t('arkCustodyCustodial'), desc: t('arkCustodyCustodialDesc'), cost: t('arkCustodyCostNone') },
+ { id: 'self', label: t('arkCustodySelf'), desc: t('arkCustodySelfDesc', { days: lifeDays }), cost: t('arkCustodyCostSelf', { base: baseCost }) },
+ { id: 'exit', label: t('arkCustodyExit'), desc: t('arkCustodyExitDesc', { hops }), cost: t('arkCustodyCostExit', { pct: earlyPct, base: baseCost }) },
+ ].filter((x) => honors || x.id !== 'custodial');
+ const chosen = tiers.find((x) => x.id === cur) || tiers[0];
+ // A drop-down, and only the chosen strategy's description under it: three
+ // paragraphs at once read as a wall, and two of them are about choices
+ // the reader has not made.
+ return h('div', { class: 'card col ark-custody', style: 'gap:10px' },
+ h('h4', { style: 'margin:0' }, t('arkCustodyTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })),
+ h('select', { id: 'ark-custody-select', disabled: !honors, onChange: (e) => applyCustody(e.target.value) },
+ ...tiers.map((x) => h('option', { value: x.id, selected: x.id === cur }, x.label))),
+ // the price under the choice, not in the option (a long option text
+ // overflows the select on a phone)
+ h('div', { class: 'small ark-custody-cost' }, chosen.cost),
+ h('p', { class: 'small muted ark-custody-desc', style: 'margin:0' }, chosen.desc),
+ honors ? null : h('p', { class: 'small faint', style: 'margin:0' }, t('arkCustodyNotHere')));
+ }
+
// What each server charges and promises, from its own published info —
// laid side by side under the switcher so the choice is an informed one.
const serverInfos = new Map(); // url -> info | null (failed) | undefined (loading)
@@ -2776,9 +2949,9 @@ export function arkFeature(ctx) {
// Fee = size × rate needs none of it. Still memoized on the coin set +
// feerate, since the advisory asks on every wallet render.
let _exitFeeMemo = { key: null, val: 0 };
- function estimateExitFeeSat(mgr) {
+ function estimateExitFeeSat(mgr, only = null) {
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
- const coins = freeCoins(mgr.state);
+ const coins = only || exitableCoins(mgr.state, mgr._tipH || 0);
const key = feeRate + '|' + coins.map((v) => v.id).join(',');
if (_exitFeeMemo.key === key) return _exitFeeMemo.val;
let total = 0;
@@ -2793,16 +2966,27 @@ export function arkFeature(ctx) {
return total;
}
- async function doArkExit() {
+ // ids: exactly these coins (the Manage page's ticked ones); else every coin
+ async function doArkExit(ids = null) {
ui.arkBusy = 'exit'; ui.arkError = ''; render();
try {
- const mgr = await connectArk();
- const spendables = freeCoins(mgr.state);
- if (!spendables.length) throw new Error(t('arkNotConnected'));
+ // The manager's own init is enough for an exit — the server may be
+ // gone (that is what a unilateral exit is for), and the snapshot gate
+ // the full connect also waits on may never clear without the relays.
+ let mgr = arkView();
+ if (!mgr) {
+ connectArk().catch(() => {});
+ for (let i = 0; i < 50 && !arkInitPromise; i++) await new Promise((r) => setTimeout(r, 100));
+ try { await arkInitPromise; } catch {}
+ mgr = arkView();
+ }
+ if (!mgr) throw new Error(t('arkNotConnected'));
+ const spendables = exitableCoins(mgr.state, mgr._tipH || 0).filter((v) => !ids || ids.includes(v.id));
+ if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected'));
// Fees are paid hop by hop from Savings as the exit unrolls — an exit
// that can't fund its hops just stalls at step zero. Refuse to start
// one Savings can't carry, with the number on the table.
- const feeEst = estimateExitFeeSat(mgr);
+ const feeEst = estimateExitFeeSat(mgr, spendables);
if ((wallet.spendable || 0) < feeEst) {
throw new Error(t('arkExitFeeShort', { need: fmtSats(feeEst), have: fmtSats(wallet.spendable || 0) }));
}
@@ -2960,7 +3144,7 @@ export function arkFeature(ctx) {
load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } },
save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} },
},
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
+ arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired,
});
await mgr.init();
return mgr;
@@ -3008,7 +3192,7 @@ export function arkFeature(ctx) {
// Fund a fresh bearer gift identity. Shared by the gifts feature's hook and
// the zap flow's locked-gift fallback; `lockedTo` records who a locked gift
// was for (the sender can still revoke either kind while unclaimed).
- async function createArkGift(amountSat, lockedTo = null) {
+ async function createArkGift(amountSat, lockedTo = null, note = null) {
const mgr = await connectArk();
const secret = crypto.getRandomValues(new Uint8Array(32));
const secretHex = hex.encode(secret);
@@ -3018,7 +3202,7 @@ export function arkFeature(ctx) {
const action = mgr.state.actions.find((a) => a.id === actionId);
if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed'));
const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id;
- arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) });
+ arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}), ...(note ? { note } : {}) });
mgr._save();
return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address };
}
@@ -3052,7 +3236,7 @@ export function arkFeature(ctx) {
async function quoteOffboard(o, address) {
o.quoteFor = address; o.quote = null;
let quote = null;
- try { quote = await (await connectArk()).offboardQuote(addrScript(address)); }
+ try { quote = await (await connectArk()).offboardQuote(addrScript(address), o.vtxoIds); }
catch {} // no quote: the send itself still says what went wrong
if (o.quoteFor !== address) return; // the address changed meanwhile
o.quote = quote; o.quoteDone = true;
@@ -3060,7 +3244,9 @@ export function arkFeature(ctx) {
}
function arkOffboardSendView() {
const o = ui.arkOffboardSend;
- const spendable = arkBalance()?.spendableSat || 0;
+ // chosen coins: the amount is theirs and is not a field
+ const chosen = o.vtxoIds ? freeCoins(arkStateNow()).filter((v) => o.vtxoIds.includes(v.id)) : null;
+ const spendable = chosen ? chosen.reduce((n, v) => n + v.amountSat, 0) : (arkBalance()?.spendableSat || 0);
if (o.quoteFor !== o.address) {
o.quote = null; o.quoteDone = false; o.quoteFor = o.address;
let ok = false; try { addrScript(o.address); ok = true; } catch {}
@@ -3070,6 +3256,7 @@ export function arkFeature(ctx) {
const tooSmall = !!q && spendable < q.minSat;
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')),
+ o.fromCoins ? h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')) : null,
h('label', { class: 'field' },
h('span', { class: 'lab' }, t('recipient')),
h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none',
@@ -3080,10 +3267,11 @@ export function arkFeature(ctx) {
fee: fmtSats(q.totalSat), chain: fmtSats(q.chainSat), service: fmtSats(q.serviceSat), min: fmtSats(q.minSat),
})) : !o.quoteDone ? h('div', { class: 'small faint' }, t('arkOffboardQuoting')) : null,
tooSmall ? h('div', { class: 'notice' }, t('arkOffboardBelowMin', { n: fmtSats(spendable), min: fmtSats(q.minSat) })) : null,
- h('div', { class: 'input-group' },
- h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
- onInput: (e) => { o.amount = e.target.value; } }),
- h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
+ chosen ? h('div', { class: 'small ark-coop-chosen' }, chosen.length === 1 ? t('arkCoopSelectedAmtOne', { amount: fmtSats(spendable) + ' sats' }) : t('arkCoopSelectedAmt', { n: chosen.length, amount: fmtSats(spendable) + ' sats' }))
+ : h('div', { class: 'input-group' },
+ h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
+ onInput: (e) => { o.amount = e.target.value; } }),
+ h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: () => {
@@ -3093,7 +3281,7 @@ export function arkFeature(ctx) {
ui.arkBusy === 'offboard'
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' }))
: h('button', { class: 'btn-primary grow', disabled: tooSmall, onClick: () => {
- const amount = String(o.amount ?? '').trim();
+ const amount = chosen ? String(spendable) : String(o.amount ?? '').trim();
const sats = amount ? Number(amount) : spendable;
if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; }
try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; }
@@ -3102,7 +3290,7 @@ export function arkFeature(ctx) {
ui.arkCoopFailed = false;
ui.arkError = t('arkOffboardAmtBelowMin', { min: fmtSats(q.minSat), fee: fmtSats(q.totalSat) }); render(); return;
}
- doArkOffboard(sats >= spendable ? 0 : sats, o.address);
+ doArkOffboard(sats >= spendable ? 0 : sats, o.address, chosen ? o.vtxoIds : undefined);
} }, t('arkSendBtn'))),
// This send IS the cooperative exit. The trustless fallback only
// appears when cooperation actually failed (not when the amount was
@@ -3138,29 +3326,69 @@ export function arkFeature(ctx) {
})());
}
- async function doArkOffboard(amountSat, destAddress = null) {
+ // A set of coins adding up to exactly `amountSat`: one coin of that size,
+ // else the smallest subset of the free coins that sums to it (largest coins
+ // first, so an offboard takes as few inputs as it can). null when no subset
+ // fits. Reachable sums are built up coin by coin, capped so a balance in
+ // hundreds of dust coins can't stall the tap.
+ function exactCoins(coins, amountSat) {
+ const one = coins.find((v) => v.amountSat === amountSat);
+ if (one) return [one];
+ const sorted = [...coins].filter((v) => v.amountSat > 0 && v.amountSat < amountSat).sort((a, b) => b.amountSat - a.amountSat);
+ const reach = new Map([[0, null]]); // sum -> { coin, prev } that first reached it
+ for (const v of sorted) {
+ const add = [];
+ for (const sum of reach.keys()) {
+ const next = sum + v.amountSat;
+ if (next <= amountSat && !reach.has(next)) add.push([next, { coin: v, prev: sum }]);
+ }
+ for (const [sum, via] of add) reach.set(sum, via);
+ if (reach.has(amountSat)) break;
+ if (reach.size > 50_000) return null;
+ }
+ if (!reach.has(amountSat)) return null;
+ const picked = [];
+ for (let sum = amountSat; sum > 0;) { const via = reach.get(sum); picked.push(via.coin); sum = via.prev; }
+ return picked;
+ }
+ // The coins that leave Spending for an on-chain send of `amountSat`. An
+ // offboard moves whole vtxos, so the amount has to be covered exactly:
+ // by coins already in the wallet when they fit (a balance that happens to
+ // add up, or the halves an earlier split left behind), else an exact amount
+ // is split off by paying it to this wallet's own address. A payment spread
+ // over several inputs arrives as several coins, one per input with the last
+ // carrying the remainder, so what the split yields is every coin that lands
+ // on the receive key and adds up to the amount, not a single coin of that
+ // size: looking for one made every partial on-chain send from a balance
+ // in two or more coins time out (Jarryd, 2026-10-23, three splits and no
+ // send). Change from the split carries its own key index and stays.
+ async function carveCoins(mgr, amountSat) {
+ const free = () => freeCoins(mgr.state);
+ const have = exactCoins(free(), amountSat);
+ if (have) return have.map((v) => v.id);
+ const before = new Set(free().map((v) => v.id));
+ await mgr.send(mgr.address(), amountSat);
+ for (let i = 0; i < 30; i++) {
+ await mgr.sync().catch(() => {});
+ const landed = free().filter((v) => !before.has(v.id) && !v.keyIndex);
+ const hit = exactCoins(landed, amountSat);
+ if (hit) return hit.map((v) => v.id);
+ await new Promise((r) => setTimeout(r, 2000));
+ }
+ throw new Error(t('arkSplitTimeout'));
+ }
+
+ // vtxoIds: exactly these coins go (the coins page's ticked subset);
+ // otherwise an amount is split off the balance, or everything goes.
+ async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) {
ui.arkBusy = 'offboard'; ui.arkError = ''; ui.arkCoopFailed = false; render();
try {
const address = destAddress || wallet.freshReceive().address;
const spk = addrScript(address);
const mgr = await connectArk();
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
- let ids; // undefined = all spendable vtxos
- if (amountSat && amountSat < total) {
- let target = spendables().find((v) => v.amountSat === amountSat);
- if (!target) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !target; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!target) throw new Error(t('arkSplitTimeout'));
- }
- ids = [target.id];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ let ids = vtxoIds; // undefined = all spendable vtxos
+ if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat);
const action = await mgr.startOffboard(spk, address, ids);
ui.arkOffboardAmt = '';
ui.arkMoveOpen = false;
@@ -3176,25 +3404,63 @@ export function arkFeature(ctx) {
ui.arkBusy = null; render();
}
- // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount
- // (fragmentation breaks coin selection) and every vtxo expires at
- // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than
- // exposing a refresh button, consolidate-and-renew automatically when a
- // vtxo is inside the renewal window or fragmentation builds up. The round
- // fee this spends is the cost of keeping the money spendable at all.
- // The ASP's refresh fee brackets reward renewing LATE — under 96 blocks of
- // remaining life a renewal is free, and this ASP keeps honoring (and freely
- // rescuing) a coin even past its expiry, so aiming inside the free window
- // risks nothing. ~16 hours of margin on mainnet.
- const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96);
- // How close to expiry the SERVER's own backstop renewal is aimed. The
- // window above only helps a wallet someone opens inside it; this one is a
- // participation registered far in advance that the ASP runs on its own,
- // whether or not anybody is looking. It sits well inside renewWindow so an
- // 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);
+ // VTXO upkeep, by the user's choice of custody (Settings → Spending):
+ // custodial (the default) — nothing renews. A coin past its timelock is
+ // coinos's IOU: the server sweeps the tree and keeps cosigning spends of
+ // the coin (captaind allow_expired_arkoor), so the balance works as a v2
+ // one did, and no renewal fee is ever paid. The holder trusts coinos.
+ // self — renew in the last hours before expiry, the free ppm bracket, one
+ // base fee per round (~300 sats): a unilateral exit is always possible.
+ // exit — the same, plus a coin is renewed as soon as its history is four
+ // hops deep: an exit pays one on-chain transaction per hop, so this
+ // keeps the exit price of every coin near a fresh coin's. Costs the
+ // top bracket + base each time, so only for coins worth it.
+ // The policy's window is when THIS app renews a coin (blocks of life left);
+ // margin/grid aim the SERVER's own backstop order — a participation the ASP
+ // runs on its own, registered far in advance, snapped to a grid so every
+ // wallet's orders share one round. renewPolicy() in ark/manager.js.
+ //
+ // Consolidation is gone from here: it used to trigger at 20 coins, bill the
+ // top ppm bracket on every fresh coin, and was 84% of all renewal fees paid
+ // in Sep–Oct 2026 — the "money draining" people saw. Multi-input sends take
+ // up to MAX_SEND_INPUTS coins; small change folds into a renewal that is
+ // happening anyway, for next to nothing; the Manage page consolidates on
+ // request.
+ const CUSTODY_DEFAULT = 'custodial';
+ // The tier in force: the stored choice, except that "coinos holds my coins"
+ // exists only on a server that honors a coin past its timelock. Anywhere
+ // else (Second, a custom captaind) an expired coin is gone, so renewal is
+ // automatic there whatever is stored — the stored default stays on the
+ // record for a later switch back to a coinos server. From 2026-10-22 to
+ // 10-23 the stored default applied everywhere, and a wallet on such a
+ // server renewed nothing and warned of nothing, in the app and the worker.
+ function getCustody() {
+ let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null;
+ if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026)
+ const tier = CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT;
+ if (tier !== 'custodial') return tier;
+ const cfg = getArkConfig();
+ return cfg && !cfg.honorsExpired ? 'self' : tier;
+ }
+ function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); }
+ const custodyPolicy = () => renewPolicy(getCustody(), getNetwork());
+ // Switching: a renewing tier starts covering coins right away (the throttles
+ // are reset so the next sync registers backstops and renews what is due),
+ // and the worker's mirror learns the tier so closed-app renewals follow it.
+ // Orders already registered with the server keep running whatever the tier
+ // (there is no cancel; a spent input voids one) — they were priced when
+ // registered and renew one coin each, once.
+ function applyCustody(tier) {
+ if (!CUSTODY_TIERS.includes(tier)) return;
+ const same = tier === getCustody();
+ setCustody(tier); // the record keeps the choice even when it is already in force here
+ if (same) return;
+ arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null;
+ if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {});
+ writeBgMirror().catch(() => {});
+ toast(t('arkCustodySaved'));
+ render();
+ }
let arkAutoRefreshAt = 0;
let arkBackstopAt = 0;
// Set when the balance is too small to renew itself (total minus round fee
@@ -3225,12 +3491,38 @@ export function arkFeature(ctx) {
return true;
}
+ // Whatever the tier, a coin whose history has run very deep is renewed:
+ // the server stops cosigning spends of a coin at its depth cap, so this is
+ // what keeps such a coin spendable, and it waives the base fee for a
+ // refresh of deep coins alone — so they go in a round of their own, never
+ // mixed with coins that would make it pay. A wallet paying hourly from one
+ // change chain (a subscription) is the usual way to get here.
+ async function flattenDeepCoins(mgr, tip) {
+ const deep = mgr.deepCoins ? mgr.deepCoins() : [];
+ if (!deep.length) return false;
+ if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return false;
+ const feeSat = mgr.refreshFee(deep, tip);
+ const totalSat = deep.reduce((n, v) => n + v.amountSat, 0);
+ if (totalSat - feeSat < 330 || feeSat * 10 > totalSat) return false; // dust, or a server charging full price: left alone
+ await mgr.refresh(deep.map((v) => v.id));
+ render();
+ return true;
+ }
+
async function maybeAutoRefresh(mgr) {
if (wallet.watchOnly || !mgr || !mgr.state) return;
if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return;
+ const policy = custodyPolicy();
+ if (mgr.deepCoins && mgr.deepCoins().length) {
+ arkAutoRefreshAt = Date.now();
+ try { if (await flattenDeepCoins(mgr, await mgr.chain.tipHeight())) return; } catch {}
+ }
+ // "coinos holds my coins": nothing renews on its own and nothing warns.
+ // The server honors a coin past its timelock, and the holder chose this.
+ if (!policy) { arkRenewWarn = null; return; }
// NOT filtered by expiryRejected. A coin the server refused as expired
// can still be REFRESHED — that is the one thing an expired vtxo can
- // still do — and excluding it here meant a wallet holding nothing else
+ // always do — and excluding it here meant a wallet holding nothing else
// did nothing at all: no renewal attempt, and no warning either.
const spendables = freeCoins(mgr.state);
if (!spendables.length) { arkRenewWarn = null; return; }
@@ -3242,25 +3534,43 @@ export function arkFeature(ctx) {
// stamped, and this must not then run on every five-second sync.
if (Date.now() - arkBackstopAt > 30 * 60_000) {
arkBackstopAt = Date.now();
- await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {});
+ await mgr.ensureScheduledRefreshes(policy.margin, 8, policy.grid).catch(() => {});
}
// never start a round while any other action is still in flight
if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
arkAutoRefreshAt = Date.now();
try {
const tip = await mgr.chain.tipHeight();
- const RENEW_BLOCKS = renewWindow();
- const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS);
- const expiring = expiringCoins.length > 0;
- // Fragmentation alone is no longer worth paying for: multi-input sends
- // handle up to 24 coins, and consolidating FRESH coins bills the round
- // fee's top ppm bracket (a burst of refund coins after failed payments
- // once cost a user ~1% overnight). Consolidate only when the coin count
- // itself approaches the 24-input send ceiling.
- const fragmented = spendables.length >= 20;
+ const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < policy.window);
+ // The exit tier also renews a coin whose history has grown deep — each
+ // hop is one more on-chain transaction in a unilateral exit — but only
+ // a coin worth it: never more than a tenth of the coin on its renewal
+ // (300 sats to flatten a 21-sat coin's history would be absurd, and
+ // such a coin's exit is uneconomic whatever its depth).
+ const depthOf = (v) => { try { return mgr._decoded(v).genesis.length; } catch { return 0; } };
+ const deepCoins = !policy.maxDepth ? [] : spendables.filter((v) =>
+ !expiringCoins.includes(v) && depthOf(v) >= policy.maxDepth && mgr.refreshFee([v], tip) * 10 <= v.amountSat);
+ if (!expiringCoins.length && !deepCoins.length) { arkRenewWarn = null; return; }
+ // Worth it, or not at all: an automatic renewal never spends more than
+ // a tenth of what it renews. 301 sats to renew a 1,000-sat coin and
+ // four bits of change restored an exit nobody would ever use (the
+ // exit itself would cost more than the coins). Coins left alone stay
+ // spendable — the server keeps honoring them.
+ const worthIt = (set) => mgr.refreshFee(set, tip) * 10 <= set.reduce((n, v) => n + v.amountSat, 0);
+ if (!expiringCoins.length) {
+ // nothing is expiring: this round is for the deep coins alone, and
+ // they pay for it — the floor logic below still applies
+ arkRenewWarn = null;
+ let renewSet = [...deepCoins, ...spendables.filter((v) => !deepCoins.includes(v) && v.amountSat < 1000)];
+ const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
+ if (netSat(renewSet) < 330 || !worthIt(renewSet)) return;
+ await mgr.refresh(renewSet.map((v) => v.id));
+ render();
+ return;
+ }
const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0);
const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330;
- if (expiring && !renewable) {
+ if (!renewable) {
// Can't save these coins ourselves — tell the user while receiving
// any amount (or spending) can still beat the clock.
const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity));
@@ -3271,26 +3581,25 @@ export function arkFeature(ctx) {
return;
}
arkRenewWarn = null;
- if (!expiring && !fragmented) return;
// Renew ONLY the coins that need it: dragging far-from-expiry coins
// into the round would bill their (higher) fee bracket for nothing.
- // The rare fragmentation consolidation still sweeps everything.
- //
- // But the subset must clear the server's 330-sat output floor BY
- // ITSELF — the renewable check above prices the whole balance. Two
- // dust coins at expiry (133 sats between them) failed their renewal
- // round silently every 30 minutes forever. Pull in the smallest
- // healthy coins until the renewed output is mintable.
- let renewSet = expiringCoins;
- if (!fragmented) {
- const extras = spendables
- .filter((v) => !expiringCoins.includes(v))
- .sort((a, b) => a.amountSat - b.amountSat);
- const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
- while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()];
- if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it
- }
- await mgr.refresh(fragmented ? undefined : renewSet.map((v) => v.id));
+ // Small change rides along, though: a coin under 1,000 sats costs at
+ // most 2 sats of ppm in this round, and folding it in now spares it a
+ // whole base fee's worth of round of its own when its turn comes.
+ let renewSet = [...expiringCoins, ...deepCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && !deepCoins.includes(v) && v.amountSat < 1000)];
+ // The set must clear the server's 330-sat output floor BY ITSELF — the
+ // renewable check above prices the whole balance. Two dust coins at
+ // expiry (133 sats between them) failed their renewal round silently
+ // every 30 minutes forever. Pull in the smallest healthy coins until the
+ // renewed output is mintable.
+ const extras = spendables
+ .filter((v) => !renewSet.includes(v))
+ .sort((a, b) => a.amountSat - b.amountSat);
+ const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
+ while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()];
+ if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it
+ if (!worthIt(renewSet)) return; // not worth a tenth of itself: left alone, still spendable
+ await mgr.refresh(renewSet.map((v) => v.id));
render();
} catch {} // transient — the next throttled attempt retries
}
@@ -3572,7 +3881,7 @@ export function arkFeature(ctx) {
const b = arkBalance() || { spendableSat: 0, pendingSat: 0 };
const spendable = b.spendableSat;
const total = b.spendableSat + b.pendingSat;
- const nSpend = freeCoins(arkStateNow()).length;
+ const nSpend = exitableCoins(arkStateNow(), (ark && ark._tipH) || 0).length;
const exitingSat = ((arkStateNow() && arkStateNow().actions) || [])
.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step))
.reduce((n, a) => n + a.amountSat, 0);
@@ -3592,7 +3901,7 @@ export function arkFeature(ctx) {
h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
spendable > 0
? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: () => doArkOffboard() },
- ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() }))
+ ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtNative(spendable) }))
: h('div', { class: 'small faint' }, exitingSat > 0 ? t('arkExitCoopHeld') : t('arkExitNoBalance'))),
// unilateral
h('div', { class: 'card col', style: 'gap:8px' },
@@ -3627,10 +3936,10 @@ export function arkFeature(ctx) {
new Promise((r) => setTimeout(r, 800)),
]);
},
- // the Spending face's settings page leads with its coins
+ // the Spending face's settings page: the custody choice, then its coins
spendingSettings() {
if (wallet.watchOnly) return null; // a locked wallet can't renew or exit
- return arkCoinsPage({ embedded: true });
+ return arkCoinsPage({ embedded: true }); // the custody strategy sits inside, after the coins
},
// the wallet's own pages keep the top nav: these are pages, not takeovers
screenView() {
@@ -3817,9 +4126,21 @@ export function arkFeature(ctx) {
async arkBgNoteSpends(spends) {
const mgr = await connectArk();
for (const sp of spends) {
+ // The worker's own manager wrote this payment's row into the mirror,
+ // and the merge on connect brings it over: a second row here showed
+ // every closed-app payment twice (PullCap, 2026-10-23). Stamp the
+ // row that exists instead; lnFoldedIds hides a twin that still
+ // forms when the merge lands after this note.
+ const twin = (mgr.state.movements || []).find((m) => m.type === 'ln-send'
+ && ((sp.preimage && m.preimage === sp.preimage) || (sp.paymentHash && m.paymentHash === sp.paymentHash)));
+ if (twin) {
+ if (!/while closed/.test(twin.detail || '')) twin.detail = `while closed · fee ${sp.feeSat || 0} sat`;
+ continue;
+ }
mgr._movement({
type: 'ln-send', amountSat: sp.amountSat, status: 'complete',
detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '',
+ paymentHash: sp.paymentHash || undefined,
});
}
mgr._save();
@@ -3907,9 +4228,9 @@ export function arkFeature(ctx) {
// ⚡ button) — through the same resolution ladder as a pasted npub. The
// event id rides into the receipt's e tag; callers fall back to the
// lnZapNpub hook when ark can't serve this build/wallet.
- zapNpub(pk, npub, eventId, autoSat) {
+ zapNpub(pk, npub, eventId, autoSat, moment) {
if (!arkAvailable() || !wallet.nostrFetch) return false;
- startNpubPay(pk, npub, eventId || null, autoSat || 0);
+ startNpubPay(pk, npub, eventId || null, autoSat || 0, moment);
return true;
},
sendFormExtras() {
@@ -3953,7 +4274,7 @@ export function arkFeature(ctx) {
const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash));
const lnPending = (s.actions || [])
.filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash))
- .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; });
+ .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) }; });
// Same-payment duplicates (the old settle race, or a snapshot adopted
// whole before the merge learned to dedupe) fold at display time too —
// the earliest telling wins, everywhere, however the state got here.
@@ -3993,7 +4314,22 @@ export function arkFeature(ctx) {
for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id);
const out = [];
const seenCoin = new Set();
+ // One payment delivered as several coins (a gift held in two, a
+ // dust-padded split) is one row: receives from the same mailbox
+ // message (`batch`; older rows: the same delivery instant) fold.
+ const folded = new Map();
for (const m of moves) {
+ if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) {
+ const last = out[out.length - 1];
+ if (!folded.has(last.id)) {
+ const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null };
+ out[out.length - 1] = f; folded.set(f.id, f);
+ }
+ const f = folded.get(last.id);
+ f.amountSat += m.amountSat;
+ f.vtxoIds.push(m.vtxoId);
+ continue;
+ }
if (m.type !== 'reconcile') { out.push(m); continue; }
if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue;
if (m.vtxoId) seenCoin.add(m.vtxoId);
@@ -4006,7 +4342,8 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
- return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })));
+ this._histFolded = folded; // a folded row's detail shows the whole payment
+ return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
if (ui.arkExitDetail) {
@@ -4025,7 +4362,8 @@ export function arkFeature(ctx) {
if (done) return arkMoveDetailView(done);
if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
}
- const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
+ if (s) this.historyEntries(); // (memoized) keeps the folded rows current
+ const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
return null;
@@ -4090,7 +4428,11 @@ export function arkFeature(ctx) {
// and it is one tap away on the Manage page. What it cannot do is be
// spent over Ark or exited unilaterally, so saying nothing is the one
// option that isn't honest.
- if (!wallet.watchOnly && ark && ark.balance) {
+ // On the coinos servers a coin past its timelock is still money (the
+ // server keeps cosigning its spends) — the balance counts it and
+ // there is nothing to warn about; the custody tier the user chose
+ // says what expiry means to them. Elsewhere, the old warning.
+ if (!wallet.watchOnly && ark && ark.balance && !ark.honorsExpired) {
let expiredSat = 0;
try { expiredSat = ark.balance().expiredSat || 0; } catch {}
// Expired dust (under the 330-sat floor) cannot be renewed or exited
@@ -4172,7 +4514,7 @@ export function arkFeature(ctx) {
setNetwork(g.net);
return true;
},
- arkGiftCreate(amountSat) { return createArkGift(amountSat); },
+ arkGiftCreate(amountSat, note) { return createArkGift(amountSat, null, note); },
// Claim-side status: claimable (with the live amount), taken, wrongnet, or
// unknown (not visible here — wrong server or not yet delivered).
async arkGiftStatus(code) {
diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js
index 824ddc0..9532125 100644
--- a/src/features/gifts-wallet.js
+++ b/src/features/gifts-wallet.js
@@ -274,9 +274,9 @@ export function installGiftWallet(wallet) {
// gift's link/QR can be re-viewed later and a claimed one identified in history.
_giftRecordsKey() { return this._cacheKey() + ':giftrec'; },
- recordGift({ code, locked, amount, claimCode, outpoints }) {
+ recordGift({ code, locked, amount, claimCode, outpoints, note, from }) {
if (!outpoints || !outpoints.length) return;
- this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false };
+ this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false, ...(note ? { note } : {}), ...(from ? { from } : {}) };
this._saveGiftRecords();
try { this.saveCache(); } catch {} // publish the new gift to cross-device sync
},
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 56fd99e..42e794d 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -9,13 +9,17 @@ import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredG
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
import { fmtBtc, timeAgo, shortAddr } from '../format.js';
+import { getEventHash, verifyEvent } from 'nostr-tools/pure';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+import { base32nopad } from '@scure/base';
export function giftsFeature(ctx) {
const {
h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal,
fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download,
brandHeader, activeAccount, setAccounts, getAccounts, claimTargets,
- enterWallet, activateAccount, commitAccount,
+ enterWallet, activateAccount, commitAccount, pickConfirm,
} = ctx;
installGiftWallet(wallet); // gift primitives live outside the core wallet
// Ark-gift support comes from the ark feature (when this build ships it);
@@ -32,6 +36,8 @@ export function giftsFeature(ctx) {
try {
const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift
if (am) {
+ ui.claimNote = readNoteFrag();
+ ui.claimFrom = readFromFrag(am[1]);
history.replaceState(null, '', '/');
return arkGiftOf(am[1]) ? am[1] : null;
}
@@ -43,6 +49,7 @@ export function giftsFeature(ctx) {
if (hm) code = hm[1];
}
if (!code) return null;
+ if (pm) { ui.claimNote = readNoteFrag(); ui.claimFrom = readFromFrag(code); }
history.replaceState(null, '', '/');
return previewGift(code) ? code : null;
} catch {
@@ -57,6 +64,8 @@ export function giftsFeature(ctx) {
try {
const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/);
if (!m) return null;
+ ui.claimNote = null;
+ ui.claimFrom = null;
history.replaceState(null, '', '/');
return previewLockedGift(m[1]);
} catch { return null; }
@@ -120,6 +129,77 @@ export function giftsFeature(ctx) {
if (!acceptLockedPayload(payload)) { ui.claimError = t('claimCodeWrong'); render(); }
}
+ // A gift's note rides its link after the # (#m=<text>): the fragment never
+ // leaves the browser, so no server sees it, and it is only as private as the
+ // link itself (which already IS the money).
+ const GIFT_NOTE_MAX = 280;
+ // Who it's from rides there too (f=…): the sender's nostr key, signed over
+ // the gift's code, so a link can't be passed on as someone else's gift.
+ // 100 bytes in base32: pubkey, created_at, schnorr sig.
+ const giftFrag = (note, from) => {
+ const p = [];
+ if (note) p.push('m=' + encodeURIComponent(note));
+ if (from) p.push('f=' + from);
+ return p.length ? '#' + p.join('&') : '';
+ };
+ function readNoteFrag() {
+ try {
+ const m = new URLSearchParams(location.hash.slice(1)).get('m');
+ return m ? m.trim().slice(0, GIFT_NOTE_MAX) || null : null;
+ } catch { return null; }
+ }
+ const GIFT_FROM_KIND = 27121; // ephemeral range: never meant for a relay
+ const giftFromTmpl = (code, created_at) => ({
+ kind: GIFT_FROM_KIND, created_at, content: '',
+ tags: [['x', bytesToHex(sha256(new TextEncoder().encode(String(code).toUpperCase())))]],
+ });
+ // the sender's pubkey, if the link's f= is their signature over this code
+ function readFromFrag(code) {
+ try {
+ const f = [...new URLSearchParams(location.hash.slice(1))].find(([k]) => k.toLowerCase() === 'f')?.[1];
+ const b = f && base32nopad.decode(f.toUpperCase());
+ if (!b || b.length !== 100) return null;
+ const ev = { ...giftFromTmpl(code, new DataView(b.buffer, b.byteOffset).getUint32(32)),
+ pubkey: bytesToHex(b.slice(0, 32)), sig: bytesToHex(b.slice(36)) };
+ ev.id = getEventHash(ev);
+ return verifyEvent(ev) ? ev.pubkey : null;
+ } catch { return null; }
+ }
+ // Sign a new gift as ours (the messages feature holds the identity); null
+ // when there's no identity, the signer refuses, or it takes too long.
+ async function signGiftFrom(code) {
+ try {
+ const ev = await Promise.race([
+ hook('signGiftFrom', giftFromTmpl(code, Math.floor(Date.now() / 1000))),
+ new Promise((r) => setTimeout(() => r(null), 20_000)),
+ ]);
+ if (!ev || !ev.sig) return null;
+ const b = new Uint8Array(100);
+ b.set(hexToBytes(ev.pubkey), 0);
+ new DataView(b.buffer).setUint32(32, ev.created_at);
+ b.set(hexToBytes(ev.sig), 36);
+ return base32nopad.encode(b);
+ } catch { return null; }
+ }
+ const GIFT_ANON_KEY = 'giftAnon';
+ const giftAnon = () => { try { return localStorage.getItem(GIFT_ANON_KEY) === '1'; } catch { return false; } };
+ // who sent it, and that claiming follows them
+ const giftFromView = () => (ui.claimFrom
+ ? h('div', { class: 'col', style: 'align-items:center;gap:4px' },
+ h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' },
+ h('span', { class: 'small muted' }, t('giftFrom')),
+ hook('profileChip', ui.claimFrom, undefined, () => {})
+ || h('span', { class: 'small mono' }, (npubOf(ui.claimFrom) || '').slice(0, 16) + '…')),
+ h('div', { class: 'small faint' }, t('giftClaimFollows')))
+ : null);
+ // the sender's words, quoted above the amount wherever the gift is opened
+ const giftNoteView = () => (ui.claimNote
+ ? h('blockquote', { class: 'gift-note' }, ui.claimNote)
+ : null);
+ // with a note or a sender the link has a # in it: a byte-mode QR (the
+ // bare link is upper-cased into the denser alphanumeric mode)
+ const giftQr = (url, hasFrag) => qrSvg(hasFrag ? url : url.toUpperCase(), { ec: 'L', mode: hasFrag ? undefined : 'Alphanumeric' });
+
// Entry point for claiming a gift code. If the recipient already has wallet(s),
// offer to claim into one or make a new one; otherwise go straight to a fresh
// wallet (the original first-timer flow).
@@ -171,6 +251,8 @@ export function giftsFeature(ctx) {
h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ giftFromView(),
+ giftNoteView(),
pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null,
h('div', { class: 'small faint' }, claimFeeNote(code)),
h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt'))
@@ -214,7 +296,7 @@ export function giftsFeature(ctx) {
),
h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))),
h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')),
- h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification'))
+ h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
)
);
}
@@ -246,13 +328,11 @@ export function giftsFeature(ctx) {
onClick: () => {
const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; }
- ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = '';
- landAccount();
- render();
+ finishClaim();
},
}, t('openWallet'))
),
- h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification'))
+ h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
)
);
}
@@ -313,6 +393,8 @@ export function giftsFeature(ctx) {
h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ giftFromView(),
+ giftNoteView(),
h('div', { class: 'amt', style: 'font-size:30px' },
h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit')
),
@@ -324,10 +406,12 @@ export function giftsFeature(ctx) {
!ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')),
ui.busy || ui.claimChecking
? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
+ // an ark gift costs nothing to claim: just the button (an on-chain
+ // one says what its network fee will take)
: h('button', { class: 'btn-primary btn-block', onClick: doClaim },
- t('claimBtn'), ' ',
- h('span', { style: 'font-size:0.85em;opacity:0.9' },
- '(' + (ag ? t('claimArkFree') : t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() })) + ')'))
+ ...(ag ? [t('claimNowBtn')] : [t('claimBtn'), ' ',
+ h('span', { style: 'font-size:0.85em;opacity:0.9' },
+ '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')]))
);
}
@@ -388,10 +472,21 @@ export function giftsFeature(ctx) {
if (ctx.setAccount) ctx.setAccount('spending');
}
+ // The seed is written down: into the new wallet — by way of the
+ // wizard's username, avatar and cover, so a gift's recipient arrives as
+ // someone people can pay by name (skippable, like any first run).
+ function finishClaim() {
+ ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = '';
+ landAccount();
+ if (ctx.offerProfileSetup) ctx.offerProfileSetup({ home: ui.claimVia === 'ark' ? 'spending' : 'savings' });
+ render();
+ }
+
function afterClaim(amount) {
ui.claimedAmount = amount;
const wasProvisional = !!(activeAccount() && activeAccount().provisional);
commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one)
+ if (ui.claimFrom) hook('followGiftSender', ui.claimFrom);
if (wasProvisional) {
ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up
} else {
@@ -526,13 +621,14 @@ export function giftsFeature(ctx) {
// the sender can re-share an unclaimed gift.
function viewGiftView() {
const g = ui.viewGift; // { code, locked, amount, claimCode }
- const url = `${location.origin}/g/${g.code}`;
+ const url = `${location.origin}/g/${g.code}${giftFrag(g.note, g.from)}`;
let svg = null;
- try { svg = qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ try { svg = giftQr(url, !!(g.note || g.from)); } catch {}
return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' },
h('div', { class: 'card col', style: 'gap:12px;align-items:center' },
h('h3', { style: 'margin:0' }, t('giftView')),
g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null,
+ g.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, g.note) : null,
svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url),
copyBtn(url, t('copyLink')),
@@ -567,9 +663,9 @@ export function giftsFeature(ctx) {
}
ui.busy = true; ui.giftError = ''; render();
try {
- const g = await hook('arkGiftCreate', amt);
+ const g = await hook('arkGiftCreate', amt, giftNoteNow());
ui.giftIsArk = true;
- finishGift(g, amt);
+ await finishGift(g, amt);
} catch (e) {
ui.giftError = e.message;
}
@@ -611,12 +707,21 @@ export function giftsFeature(ctx) {
doCreateGift(amt, rate);
}
- // Finalize a freshly-built gift: a plain bearer link.
- function finishGift(g, fallbackAmt) {
+ // Finalize a freshly-built gift: a plain bearer link, signed as ours
+ // unless the sender would rather stay anonymous. Recorded before the
+ // signer is asked, so a signer that never answers can't lose the gift.
+ async function finishGift(g, fallbackAmt) {
const amount = g.amount || fallbackAmt || 0;
+ const note = giftNoteNow(); // the link says what was typed when it was made
+ const rec = { code: g.code, locked: false, amount, outpoints: g.reserved, note };
+ wallet.recordGift(rec);
+ const from = giftAnon() ? null : await signGiftFrom(g.code);
+ if (from) wallet.recordGift({ ...rec, from });
ui.giftCode = g.code;
- wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved });
+ ui.giftNoteMade = note;
+ ui.giftFromMade = from;
}
+ const giftNoteNow = () => String(ui.giftNote || '').trim().slice(0, GIFT_NOTE_MAX) || null;
function giftCard() {
const active = wallet.outstandingGifts();
@@ -631,13 +736,14 @@ export function giftsFeature(ctx) {
// (many-input) gift can exceed QR capacity — then fall back to the link.
(() => {
let svg = null;
- try { svg = qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ try { svg = giftQr(giftUrl(), !!(ui.giftNoteMade || ui.giftFromMade)); } catch {}
return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong'));
})(),
+ ui.giftNoteMade ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, ui.giftNoteMade) : null,
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()),
h('div', { class: 'row gap6 wrap' },
copyBtn(giftUrl(), t('copyLink')),
- h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
+ h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftNote = ''; ui.giftNoteMade = null; ui.giftFromMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
)
)
: ui.giftSplitOffer
@@ -692,6 +798,15 @@ export function giftsFeature(ctx) {
ui.giftSource === 'chain'
? h('div', { class: 'notice warn small' }, t('giftChainWarn'))
: null,
+ // an optional note, shown to whoever opens the gift
+ h('textarea', { class: 'gift-note-input', rows: 2, maxlength: GIFT_NOTE_MAX, placeholder: t('giftNotePlaceholder'),
+ value: ui.giftNote || '', onInput: (e) => { ui.giftNote = e.target.value; } }),
+ // the link names its sender (and claiming follows them) unless unticked
+ h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', checked: !giftAnon(), onChange: (e) => {
+ try { if (e.target.checked) localStorage.removeItem(GIFT_ANON_KEY); else localStorage.setItem(GIFT_ANON_KEY, '1'); } catch {}
+ } }),
+ h('span', { class: 'small' }, t('giftFromMe'))),
ui.giftError && h('div', { class: 'notice err' }, ui.giftError),
ui.busy
? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
@@ -864,36 +979,40 @@ export function giftsFeature(ctx) {
// Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims
// into a fresh wallet only they control. The coin is reserved until claimed.
function giftUrl() {
- return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}`;
+ return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}${giftFrag(ui.giftNoteMade, ui.giftFromMade)}`;
}
function giftRate() {
return (wallet.feeRates && wallet.feeRates.halfHourFee) || 5;
}
- function doCreateGift(amt, rate) {
+ async function doCreateGift(amt, rate) {
+ ui.busy = true; render();
try {
- finishGift(wallet.createGift(amt, rate), amt);
+ await finishGift(wallet.createGift(amt, rate), amt);
ui.giftError = '';
ui.giftSplitOffer = null;
} catch (e) {
ui.giftError = e.message;
ui.giftSplitOffer = null;
}
+ ui.busy = false;
render();
}
// Gift the whole spendable balance as a no-change sweep (the recipient receives
// everything minus their claim fee).
- function doCreateGiftAll(rate) {
+ async function doCreateGiftAll(rate) {
+ ui.busy = true; render();
try {
- finishGift(wallet.createGiftAll(rate));
+ await finishGift(wallet.createGiftAll(rate));
ui.giftError = '';
ui.giftMax = false;
ui.giftSplitOffer = null;
} catch (e) {
ui.giftError = e.message;
}
+ ui.busy = false;
render();
}
@@ -906,7 +1025,7 @@ export function giftsFeature(ctx) {
ui.busy = true; ui.giftError = ''; render();
try {
const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5;
- finishGift(await wallet.createGiftFromSplit(amt, rate), amt);
+ await finishGift(await wallet.createGiftFromSplit(amt, rate), amt);
ui.giftSplitOffer = null;
ui.giftError = '';
} catch (e) {
diff --git a/src/features/hats.js b/src/features/hats.js
index a14fc2b..932f908 100644
--- a/src/features/hats.js
+++ b/src/features/hats.js
@@ -222,9 +222,52 @@ export function hatsFeature(ctx) {
}
}
+ // ---- the whole list ------------------------------------------------------
+ // Only a couple of dozen heads wear a hat, so the registrar hands over all
+ // of them (/hats/worn) and every avatar is answered from that, even one
+ // seen for the first time; per-head lookups were 400 ms + a round trip
+ // after the page had painted. Public data, so it's kept per device, not per
+ // account. Until it has arrived once, the per-head path above still runs.
+ const ALL_KEY = (net) => 'hat-worn-all:' + net;
+ const allWorn = {}; // net -> { hats: {pk: id}, t }
+ const readAll = (net) => {
+ if (!(net in allWorn)) {
+ try { allWorn[net] = JSON.parse(localStorage.getItem(ALL_KEY(net)) || 'null'); } catch { allWorn[net] = null; }
+ }
+ return allWorn[net];
+ };
+ const allBusy = {}, allFailedAt = {};
+ async function fetchAll(net) {
+ if (allBusy[net] || Date.now() - (allFailedAt[net] || 0) < 60_000) return;
+ allBusy[net] = true;
+ try {
+ const r = await fetch(`${REGISTRAR}/hats/worn?net=${net}`);
+ if (!r.ok) throw new Error('hats list failed');
+ const j = await r.json();
+ const hats = j.hats || {};
+ const prev = readAll(net);
+ allWorn[net] = { hats, t: Date.now() };
+ try { localStorage.setItem(ALL_KEY(net), JSON.stringify(allWorn[net])); } catch {}
+ if (!prev || JSON.stringify(prev.hats) !== JSON.stringify(hats)) render();
+ } catch {
+ allFailedAt[net] = Date.now();
+ } finally {
+ allBusy[net] = false;
+ }
+ }
+ const fromAll = (pk) => {
+ const net = hatNet();
+ const all = readAll(net);
+ if (!all || Date.now() - all.t > REVALIDATE) fetchAll(net);
+ return all ? { hat: all.hats[pk] || null } : null;
+ };
+ setTimeout(() => { try { fromAll(''); } catch {} }, 0); // warm before the first avatar
+
// Stale-while-revalidate: answer from cache immediately, refresh behind.
function hatFor(pk) {
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null;
+ const known = fromAll(pk);
+ if (known) return known.hat;
loadStore();
const v = worn.get(hatNet() + ':' + pk);
if (!v || Date.now() - v.t > REVALIDATE) queueFetch(pk);
diff --git a/src/features/messages.js b/src/features/messages.js
index 59dc966..e1468ce 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,3 +1,5 @@
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js';
+import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
// Communities are end-to-end private: there is no global discovery in
@@ -13,10 +15,11 @@
// listens for both identities and decrypts with whichever keys are present.
import {
- subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay,
+ subscribeOn, subscribeAuthed, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay, wakeRelays,
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,
@@ -51,6 +54,8 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
+import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
+import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr, liveReactions } from '../live.js';
import { qrSvg } from '../qr.js';
// The coinos community's join material lives in ../community.js — shared
@@ -59,7 +64,15 @@ import { COMMUNITY, EPOCH } from '../community.js';
import COMMUNITY_DIRECTORY from '../community-directory.json';
import { assetUrl } from '../asset-url.js';
import { mergeFeeds } from '../feed-sync.js';
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol'];
+// coinos's own relay and primal: both take DM gift wraps and pass call
+// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
+// refused or ignored the household IP's publishes, call answers among them.
+// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads;
+// relay.coinos.io stays on while older inbox lists still name it
+const MESSAGES_RELAY = 'wss://messages.coinos.io';
+const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net'];
+// what this app put in a wallet key's kind 10050 before messages.coinos.io
+const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -68,6 +81,7 @@ const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature st
// kind-31990 handler coordinate, which we haven't published. Deliberately
// absent from anything encrypted (DMs, community wraps) — those are private
// and need no billboard.
+const CALL_LOG = 1405; // a call, logged in its conversation (see logCall)
const CLIENT_TAG = ['client', 'coinos', '31990:72bdbc57bdd6dfc4e62685051de8041d148c3c68fe42bf301f71aa6cf53e52fb:coinos'];
// A view the URL asks to open (a notification tap): read once, then taken
@@ -87,7 +101,7 @@ export function messagesFeature(ctx) {
const gifs = createGifPicker({ h, t, ui, render });
// NIP-92: an attachment described, so clients needn't sniff the URL
const mediaImeta = (m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : []), ...(m.dim ? ['dim ' + m.dim] : []),
- ...(m.size ? ['size ' + m.size] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
+ ...(m.size ? ['size ' + m.size] : []), ...(m.duration ? ['duration ' + m.duration] : []), ...(m.alt ? ['alt ' + m.alt] : [])];
// ---- persisted state ----------------------------------------------------
@@ -169,6 +183,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 +196,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 +476,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 +702,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 +760,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
@@ -2230,6 +2289,22 @@ export function messagesFeature(ctx) {
}, 0);
}
+ // /naddr1… of a live stream (kind 30311) opens the stream, public like a
+ // note: a visitor watches over the front door. A reload the history
+ // restores by itself needs nothing.
+ const urlLive = (() => {
+ if (typeof location === 'undefined' || urlInvite) return null;
+ const m = location.pathname.match(/^\/(naddr1[a-z0-9]+)\/?$/i);
+ let ref = null; try { ref = m ? parseNostrRef(m[1].toLowerCase()) : null; } catch {}
+ return ref && ref.type === 'addr' && ref.kind === LIVE_KIND ? ref : null;
+ })();
+ if (urlLive && (() => { try { return history.state?.nav?.liveStream?.a !== LIVE_KIND + ':' + urlLive.pk + ':' + urlLive.d; } catch { return true; } })()) {
+ try { history.replaceState(null, '', '/'); } catch {}
+ ui.pubProf = true;
+ ui.pubNotePending = 'live';
+ setTimeout(() => { openLiveRef(urlLive).catch(() => {}).finally(() => { ui.pubNotePending = null; render(); }); }, 0);
+ }
+
// Resolve and open right away — not in init(), which only runs once a
// wallet opens (and would replay on every wallet born in this tab). For a
// visitor with no wallet the profile renders as a truly public page over
@@ -2351,7 +2426,7 @@ export function messagesFeature(ctx) {
// never sends what the user turned off (a suppressed-but-delivered push
// would earn Chrome's generic "updated in background" nag instead)
const want = (k) => s.reasons?.[k] !== false;
- const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction') };
+ const reasons = { payment: want('payment'), zap: want('zap'), dm: want('dm'), mention: want('mention'), reaction: want('reaction'), call: want('call') };
// Stamp the network: the same seed makes the same nostr pubkey on mainnet
// AND staging, so without this the notifier fans a mainnet payment push
// out to the staging PWA's subscription too — and tapping it opened
@@ -2415,6 +2490,7 @@ export function messagesFeature(ctx) {
rowT(t('notifDm'), 'dm'),
rowT(t('notifMention'), 'mention'),
rowT(t('notifReaction'), 'reaction'),
+ rowT(t('notifCall'), 'call'),
h('div', { class: 'small faint' }, t('notifChatHint')));
}
@@ -2422,13 +2498,17 @@ export function messagesFeature(ctx) {
// stranger's: our key (only if we actually hold one), who we follow, and the
// names we already know. See dm-inbox.js for the threat model — a remote
// signer stores no key here and simply gets unfiltered notifications.
- let inboxAt = 0;
+ let inboxAt = 0, inboxTries = 0;
async function syncInbox({ force = false } = {}) {
if (!force && Date.now() - inboxAt < 5 * 60_000) return;
inboxAt = Date.now();
try {
const id = await identity();
- if (!id) return;
+ // the keys not there yet (a wallet still opening): try again shortly —
+ // this used to count as done for five minutes, and a phone woken by a
+ // push in that time had no key to open a DM or a call with
+ if (!id) { if (++inboxTries <= 10) { inboxAt = 0; setTimeout(() => { syncInbox().catch(() => {}); }, 3000); } return; }
+ inboxTries = 0;
const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])],
{ kinds: [3], authors: [id.pubkey] }, 4000);
const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
@@ -2436,6 +2516,9 @@ export function messagesFeature(ctx) {
? [...new Set(newest.tags.filter((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1]))]
: [];
const known = [...threads.keys()];
+ // who may ring us when the app is shut: those we've written to (the
+ // follows ride separately); `known` stays the DM filter's wider list
+ const rings = known.filter((pk) => wroteTo(pk));
const names = {};
for (const pk of [...follows, ...known]) {
const p = profiles.get(pk);
@@ -2444,7 +2527,7 @@ export function messagesFeature(ctx) {
await saveInbox(wallet._cacheKey(), {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
- follows, known, names, hasList: !!newest, updated: Date.now(),
+ follows, known, rings, names, hasList: !!newest, updated: Date.now(),
muted: [...mutesNow().set], // no buzz from a muted sender either
});
} catch { inboxAt = 0; }
@@ -3040,6 +3123,10 @@ export function messagesFeature(ctx) {
} else if (got.rumor.kind === 444 && !isMe(got.author)) {
// a White Noise invitation: the group itself says who is asking
whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {});
+ } else if (got.rumor.kind === CALL_LOG && isMe(got.author)) {
+ // a call, as one of our devices logged it, under the person it was with
+ const with_ = got.rumor.tags?.find((x) => x[0] === 'p')?.[1];
+ if (with_ && /^[0-9a-f]{64}$/.test(with_)) { noteDM(with_, got.rumor, true); persistDms(); }
}
return true;
}
@@ -3049,13 +3136,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 +3183,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,13 +3242,51 @@ 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);
+ // calls ringing in (ephemeral wraps: only what comes while we listen)
+ const callSub = (relays, authed) => {
+ const f = { kinds: [CALL_WRAP], '#p': pks, since: Math.floor(Date.now() / 1000) - 20 };
+ const on = (w) => { onCallWrap(w).catch(() => {}); };
+ const u = authed ? subscribeAuthed(relays, f, on, { onauth: relayAuth, onclose: noteSubClosed }) : subscribeOn(relays, f, on, { onclose: noteSubClosed });
+ allUnsubs.push(u); dmUnsubs.push(u);
+ };
+ callSub(DM_RELAYS, false);
// Senders deliver to the relays our kind-10050 advertises — a list other
// clients may have published with relays beyond our defaults. Read those
// too, or a compliant sender's DM lands somewhere we never look (a reply
@@ -3175,6 +3301,7 @@ export function messagesFeature(ctx) {
// in Adam's list, while a cap of three read the first three)
.slice(0, 8);
ownInbox = extras;
+ if (extras.length && dmStarted) callSub(extras, true);
// inbox relays are often NIP-42 gated: authenticate as the identity
if (extras.length && dmStarted) {
const u = subscribeAuthed(extras, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => {
@@ -3192,12 +3319,19 @@ export function messagesFeature(ctx) {
// — the user's other clients own that. And when a login identity IS active,
// don't advertise the seed key at all: it's internal, not a second inbox
// (advertising it is what let DMs land on a phantom identity).
+ // A list this app wrote with its old defaults (nothing else in it) is
+ // brought up to today's, so senders find messages.coinos.io first.
async function ensureDmRelayList() {
if (!wallet.nostr || !wallet.nostr.sk) return;
if (hook('nostrLoginIdentity')) return;
const pk = wallet.nostr.pk;
const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
- if (existing.length) return;
+ if (existing.length) {
+ const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a));
+ const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, ''));
+ const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u));
+ if (!ours || urls.includes(DM_RELAYS[0])) return;
+ }
const evt = finalizeEvent({
kind: 10050,
content: '',
@@ -3294,6 +3428,10 @@ export function messagesFeature(ctx) {
if (rumor.kind === 15) {
row.k = 15;
row.tg = (rumor.tags || []).filter((x) => x[0] !== 'p');
+ } else if (rumor.kind === CALL_LOG) {
+ // a call: its who, how and how long are all in its tags
+ row.k = CALL_LOG;
+ row.tg = rumor.tags || [];
} else {
// a message's custom emoji ride along, or they'd be text after a reload
const em = [...emojiTagMap(rumor.tags)];
@@ -3794,6 +3932,15 @@ export function messagesFeature(ctx) {
node.onclick = (e) => { e.stopPropagation(); openProfile(pk); };
(node._evs || (node._evs = new Set())).add('onpointerdown').add('onclick');
}
+ // streaming now: a red ring, and the face opens the stream (the name
+ // beside it still opens the profile, which says they're on too)
+ let on = null;
+ try { on = liveHostNow(pk); } catch {} // (an avatar drawn while the feature is still being built)
+ if (on) {
+ node.classList.add('live-ring');
+ // (on that stream's own page, the face is a way out: to their profile)
+ if (clickable) node.onclick = (e) => { e.stopPropagation(); if (ui.liveStream && ui.liveStream.a === liveAddr(on)) openProfile(pk); else openLive(on); };
+ }
return hook('wrapAvatar', pk, node) || hatStamp(pk, node) || node;
};
@@ -3940,7 +4087,7 @@ export function messagesFeature(ctx) {
// The same for a post opened into its thread: Back lands on the row the
// reader tapped, not the top of the feed.
const pageReturn = []; // [{ key, y }] per profile/thread opened, newest last
- const pageKey = () => ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
+ const pageKey = () => ui.liveStream ? 'live' : ui.noteThread && !(ui.profilePk && ui.profOverThread) ? 'thread'
: ui.profilePk ? 'p:' + ui.profilePk : '';
function rememberPlace(key) {
if (pageKey() === key) return;
@@ -3971,6 +4118,7 @@ export function messagesFeature(ctx) {
}
function openProfile(pk) {
rememberPlace('p:' + pk);
+ ui.liveStream = null;
ui.profilePk = pk;
// Opened from inside a thread (an author's avatar/name), the profile
// stacks ON TOP of it — back returns to the conversation. The screen
@@ -3987,7 +4135,8 @@ export function messagesFeature(ctx) {
// ---- profile notes: latest public posts & replies -----------------------
- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
+ // (not messages.coinos.io: it takes messages, not posts)
+ const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
@@ -4006,6 +4155,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.
@@ -4330,7 +4521,81 @@ export function messagesFeature(ctx) {
// Where an event about a note goes. A zap receipt is money and has its own
// bookkeeping (see below); a like and a boost are just tallies of who.
+ const timedActivity = new Map();
+ function rememberMoment(ev) {
+ const moment = readMoment(ev);
+ if (!moment || ![1, 1111, 7, 9735, 9737].includes(ev.kind)) return;
+ const target = ev.tags.filter(x => x[0] === 'e' && x[3] !== 'mention').at(-1)?.[1];
+ if (!target) return;
+ if (!timedActivity.has(target)) timedActivity.set(target, new Map());
+ const items = timedActivity.get(target);
+ if (items.has(ev.id)) return;
+ items.set(ev.id, { id: ev.id, ev, moment,
+ icon: ev.kind === 7 ? ev.content || '♡' : [9735, 9737].includes(ev.kind) ? '⚡' : '↩',
+ text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content });
+ if (items.size > 300) items.delete(items.keys().next().value);
+ if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value);
+ queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); }));
+ }
+ // a post's timed activity on one of its clips, in order, each with who and what
+ const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])]
+ .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
+ .sort((a, b) => a.moment.seconds - b.moment.seconds)
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) }));
+ // how a moment shows on a bar: a reaction as its emoji (a :code: as its
+ // picture, from the reaction's own tag), a zap ⚡, a reply as its
+ // author's face
+ function momentGlyph(x) {
+ if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false);
+ if (x.ev.kind !== 7) return x.icon;
+ const code = shortcodeOf(x.ev.content);
+ const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
+ return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content);
+ }
+ // Where a post's clip is up to, if it has been played: a reply, reaction or
+ // zap made then is about that moment. (No buttons of their own for it: the
+ // usual ones carry the time.)
+ function momentOf(ev) {
+ if (!ev) return null;
+ const cur = pager && pager.cur;
+ const players = cur && cur._ev && cur._ev.id === ev.id
+ ? [cur.querySelector('.mp-media')]
+ : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')];
+ for (const v of players) {
+ if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime);
+ }
+ return null;
+ }
+ // A comment made at a moment of a clip opens with its time as a link that
+ // plays the clip from there; the "[0:03] " other clients read as text is
+ // left out of the words beside it.
+ function momentLink(ev) {
+ const m = readMoment(ev);
+ if (!m) return null;
+ return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => {
+ e.stopPropagation();
+ const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url);
+ if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); }
+ } }, '▶ ' + mediaClock(m.seconds));
+ }
+ const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text);
+ const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
+ function attachMediaTimeline(player, url, explicitEvent) {
+ if (player._timeline) return;
+ player._timeline = true;
+ player._momentUrl = url;
+ const event = () => {
+ const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post'));
+ return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null;
+ };
+ const box = player.parentElement;
+ box._skipMorph = true;
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph,
+ onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null }));
+ player.dispatchEvent(new Event('timeupdate'));
+ }
function noteEvent(ev) {
+ rememberMoment(ev);
if (ev.kind === 1 || ev.kind === 1111) { noteReply(ev); return; }
if (EDIT_KINDS.includes(ev.kind)) { notePostEdit(ev); return; }
if (ev.kind === 7 || ev.kind === 6) {
@@ -4368,6 +4633,7 @@ export function messagesFeature(ctx) {
// root and reply (or the bare positional e tags of older clients), NIP-22's
// E and e. A 'mention' e tag is a quote, not a reply.
function noteReply(ev) {
+ rememberMoment(ev);
let changed = false;
for (const x of ev.tags || []) {
if (!x[1] || x[1] === ev.id) continue;
@@ -4375,6 +4641,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();
}
@@ -4387,6 +4654,7 @@ export function messagesFeature(ctx) {
}
function noteReceipt(ev) {
+ rememberMoment(ev);
const sats = receiptSats(ev);
if (!sats) return;
const my = myPubkeys();
@@ -4437,6 +4705,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();
@@ -4702,6 +4972,67 @@ 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]] : [] };
+ // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at
+ // [4] only, every comment's parent lost its author, and its outbox went
+ // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the
+ // people the reply is addressed to: their outboxes are where it was written.
+ const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null);
+ ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]);
+ if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3);
+ 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.
+ // a reply whose parent every lookup came back without
+ const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; };
+ 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 noteOnDisk(id);
+ }
+ // ...or on disk with the feed it was read in: a Conversations view opened
+ // straight after a reload has the posts its replies answer in Following's
+ // cache, not in any feed state yet (and without them, those replies were
+ // taken for replies to nothing and left out)
+ const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session
+ function noteOnDisk(id) {
+ for (const f of new Set([FOLLOWING, curFeedId])) {
+ let m = diskHeld.get(f);
+ if (!m) {
+ let notes = null;
+ try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {}
+ m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e]));
+ diskHeld.set(f, m);
+ }
+ if (m.has(id)) return m.get(id);
+ }
+ return null;
+ }
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) {
@@ -4762,26 +5093,24 @@ export function messagesFeature(ctx) {
lastScrollAt = Date.now();
if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return;
windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed);
- // back at the top by yourself: the new posts are under your eyes, so
- // the notice has done its job (only the pill repaints — the rows are
- // keyed and stay put, so nothing moves under a finger)
+ // Starting to scroll up dismisses the notice: the reader is already
+ // heading toward the new posts. Keyed rows keep their positions.
// Upward only: holding the page still as posts go in above scrolls it
// DOWN, and that must not count as having gone up to look at them.
const y = window.scrollY || 0, up = y < lastFeedY;
lastFeedY = y;
- // 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 && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert)
+ // Keep tracking which posts are ahead, even after hiding the pill.
if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => {
pillFrame = 0;
if (!feed) return;
markReached(feed);
if (!feed.unseen) return;
- const n = pillCount(feed);
- if (!n) { feed.unseen = 0; render(); }
- else if (n !== feed.pillN) render();
+ feed.unseen = 0;
+ 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 });
@@ -4856,7 +5185,8 @@ export function messagesFeature(ctx) {
]);
const all = [...(relayEvs || []), ...primalEvs].filter((e) => e.pubkey === pk);
const list = all.filter((e) => e.kind === 3).sort((a, b) => b.created_at - a.created_at)[0] || null;
- return { list, existing: all.length > 0 };
+ const profile = all.filter((e) => e.kind === 0).sort((a, b) => b.created_at - a.created_at)[0] || null;
+ return { list, profile, existing: all.length > 0 };
}
// Fetch the newest list from the relays. Older than what we hold is
@@ -5171,6 +5501,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
@@ -5227,6 +5564,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;
@@ -5601,7 +5942,7 @@ export function messagesFeature(ctx) {
const own = p.pk === mePk() ? st().feeds.find((f) => f.d === p.d) : null;
const id = own ? own.id : 'list:' + packKey(p);
if (!own) adhocFeeds.set(id, { id, name: p.title || p.d, packs: [{ pk: p.pk, d: p.d, kind: p.kind === PACK_KIND ? undefined : p.kind, relays: [], title: p.title || '' }] });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null; ui.profOverThread = false;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null; ui.profOverThread = false;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -5648,7 +5989,7 @@ export function messagesFeature(ctx) {
onClick: () => {
ui.listPick = null;
// the editor lives on the feed screen; the profile gives way to it
- ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.userSearch = null;
+ ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null;
ui.chatOpen = true; ui.msgView = 'feed';
openFeedEditor(null);
ui.feedEdit.authors = [pk];
@@ -5839,15 +6180,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;
@@ -5856,14 +6198,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);
}
@@ -5907,8 +6249,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); }
@@ -5930,10 +6272,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 {}
}
}
@@ -5965,8 +6307,15 @@ export function messagesFeature(ctx) {
// ---- feeds anyone can open --------------------------------------------
// Every kind of feed has a URL, and a visitor with no wallet can read it.
const isVisitor = () => !wallet.mnemonic && !wallet.xpub && !wallet.xprv;
+ // Whose page is painted: a visitor's (no wallet on this device), or the
+ // wallet's that boot is about to restore (ctx.restorePending). The feed,
+ // reached by URL, paints before that restore lands; painted as a visitor's
+ // it had no post button, chips or nav until the wallet opened a beat later
+ // (Adam, 2026-10-31). Taps go by isVisitor(): by the time one lands the
+ // restore, synchronous, has run.
+ const visitorView = () => isVisitor() && !(ctx.restorePending && ctx.restorePending());
const showFeed = (id) => {
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
if (ui.screen === 'wallet') ui.pubProf = null; // the wallet's chrome owns it
switchFeed(id);
@@ -6042,7 +6391,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;
@@ -6065,7 +6414,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) {
@@ -6165,7 +6516,7 @@ export function messagesFeature(ctx) {
const saved = relays.length ? null : st().feeds.find((f) => !f.follows && !(f.authors || []).length && feedTopics(f).length === 1 && feedTopics(f)[0] === tag);
const id = saved ? saved.id : 'topic:' + tag + (relays.length ? '@' + relays.join(',') : '');
if (!saved && !adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '#' + tag, topics: [tag], relays });
- ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.feedEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedEdit = null;
ui.chatOpen = true; ui.msgView = 'feed';
switchFeed(id);
}
@@ -6299,21 +6650,23 @@ export function messagesFeature(ctx) {
// publish it); otherwise the metadata alone is fetched — a few KB for a
// moov-first file — with a short cap, since it gates the row. Remembered
// on disk so a clip seen once is never probed again.
- const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i;
+ const VIDEO_RE = /\.(mp4|webm|mov|m4v|m3u8)(\?[^\s]*)?$/i; // (an .m3u8 is an HLS stream: a long clip, posted the way jb55 does)
// files known to be clips without saying so in their name (divine.video's
// have no extension): an imeta tag's video/* type, or a video event's file
const videoUrls = new Set();
const isVideoUrl = (url) => VIDEO_RE.test(url) || videoUrls.has(url);
const imetaFields = (tag) => { const f = {}; for (const p of tag.slice(1)) { const i = typeof p === 'string' ? p.indexOf(' ') : -1; if (i > 0) f[p.slice(0, i)] = p.slice(i + 1); } return f; };
- // A video event's file: of the versions its imeta tags offer, the one
- // nearest 1080 on its short side (a phone's screen; not the 4K original),
- // never an HLS playlist; else its url tag.
+ // A video event's file: its HLS stream where the browser plays one (the
+ // player picks the rendition); else of the versions its imeta tags
+ // offer, the one nearest 1080 on its short side (a phone's screen; not the
+ // 4K original); else its url tag.
function videoEventFile(ev) {
- let best = null;
+ let best = null, stream = null;
for (const tag of ev.tags || []) {
if (tag[0] !== 'imeta') continue;
const f = imetaFields(tag);
- if (!/^https?:\/\//.test(f.url || '') || /mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url)) continue;
+ if (/^https?:\/\//.test(f.url || '') && (/mpegurl/i.test(f.m || '') || /\.m3u8(\?|$)/i.test(f.url))) { stream = stream || { url: f.url, image: f.image }; continue; }
+ if (!/^https?:\/\//.test(f.url || '')) continue;
if (f.m && !/^video\//.test(f.m)) continue;
const dm = /^(\d+)(?:\.0+)?x(\d+)/.exec(f.dim || '');
const edge = dm ? Math.min(+dm[1], +dm[2]) : 0;
@@ -6321,6 +6674,7 @@ export function messagesFeature(ctx) {
if (!best || score > best.score) best = { url: f.url, image: f.image, score };
}
if (!best) { const u = (ev.tags.find((x) => x[0] === 'url') || [])[1]; if (/^https?:\/\//.test(u || '')) best = { url: u }; }
+ if (stream && canHls()) best = { ...stream, image: stream.image || (best && best.image) };
if (best) videoUrls.add(best.url);
return best;
}
@@ -6399,7 +6753,8 @@ export function messagesFeature(ctx) {
const d = {};
if (!have.size && +f.size > 0) d.size = +f.size;
// no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough
- else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8);
+ // (a guess: never taken for the file's identity, see mediaItems)
+ else if (!have.size && +f.bitrate > 0 && +f.duration > 0) { d.size = Math.round(+f.bitrate * +f.duration / 8); d.sizeGuess = true; }
if (!have.dur && +f.duration > 0) d.dur = +f.duration;
const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || '');
if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; }
@@ -6438,11 +6793,20 @@ export function messagesFeature(ctx) {
// an iPhone's HEVC (hvc1/hev1 in the sample description)
let hevc = false;
for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true;
- return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) };
+ // its length, from the movie header (a long clip plays as HLS: playSrc)
+ let dur = 0;
+ for (let j = 4; j + 32 <= b.length && !dur; j++) {
+ if (b[j] !== 0x6d || type(j) !== 'mvhd') continue; // 'm'
+ const v1 = b[j + 4] === 1, scale = u32(j + (v1 ? 24 : 16));
+ const units = v1 ? u32(j + 28) * 2 ** 32 + u32(j + 32) : u32(j + 20);
+ if (scale && units && units !== 0xffffffff) dur = units / scale;
+ }
+ return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}), ...(dur ? { dur } : {}) };
}
const videoProbes = new Map();
let videoProbing = 0;
const videoProbeQueue = [];
+ const clipGone = (url) => { const d = videoInfo.get(url); return !!d && typeof d.goneAt === 'number' && Date.now() - d.goneAt < 86_400_000; };
const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000;
function probeVideo(url, layout = false) {
const have = videoInfo.get(url);
@@ -6466,6 +6830,7 @@ export function messagesFeature(ctx) {
}
}
async function runVideoProbe(url) {
+ if (isHls(url)) return probeStream(url);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10_000);
try {
@@ -6475,7 +6840,7 @@ export function messagesFeature(ctx) {
// player's plain load answers this request without them, and it fails.
// Any clip that had played in the feed was "unmeasurable" that way.
const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal });
- if (!r.ok) throw new Error('HTTP ' + r.status);
+ if (!r.ok) throw Object.assign(new Error('HTTP ' + r.status), { status: r.status });
const ranges = r.status === 206;
const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length');
// never more than the first bytes, even from a server that ignored the range
@@ -6492,25 +6857,49 @@ export function messagesFeature(ctx) {
let o = 0;
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
- const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) };
- if (total > 0) d.size = total;
+ const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
+ if (total > 0) { d.size = total; d.sizeGuess = undefined; }
else {
// a server that hides Content-Range from scripts (Primal's) still
// says Content-Length to a HEAD, which any page may read
try {
const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) });
const n = +hr.headers.get('content-length');
- if (hr.ok && n > 0) d.size = n;
+ if (hr.ok && n > 0) { d.size = n; d.sizeGuess = undefined; }
} catch {}
}
if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; }
if (head.w) rememberVideo(url, head.w, head.h);
noteVideoInfo(url, d);
- } catch {
- noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour
+ } catch (e) {
+ // a host that won't say (no CORS, down, slow): asked again in an hour.
+ // One that says the file is gone (deleted: a 404 or 410) is believed
+ // for a day, and the grid leaves the clip out (clipGone).
+ const gone = e && (e.status === 404 || e.status === 410);
+ noteVideoInfo(url, { failedAt: Date.now(), ...(gone ? { goneAt: Date.now() } : {}) });
+ if (gone && mediaMode === 'videos') scheduleRepaint();
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
+ // A stream is measured by a player: its playlist says nothing of the
+ // picture's size. Where HLS doesn't play, it can't be measured either.
+ async function probeStream(url) {
+ if (!canHls()) { noteVideoInfo(url, { failedAt: Date.now() }); return videoInfo.get(url); }
+ const v = document.createElement('video');
+ v.muted = true; v.preload = 'metadata';
+ const ok = await new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(false), 15_000);
+ v.onloadedmetadata = () => { clearTimeout(timer); resolve(!!v.videoWidth); };
+ v.onerror = () => { clearTimeout(timer); resolve(false); };
+ v.src = url;
+ });
+ if (ok) {
+ noteVideoInfo(url, { probed: true, stream: true, failedAt: undefined, ...(Number.isFinite(v.duration) ? { dur: v.duration } : {}) });
+ rememberVideo(url, v.videoWidth, v.videoHeight);
+ } else noteVideoInfo(url, { failedAt: Date.now() });
+ v.removeAttribute('src'); v.load();
+ return videoInfo.get(url);
+ }
// How much a clip may cost before it waits for a tap: less on a slow or
// metered connection.
const largeVideosOk = () => { try { return localStorage.getItem(VIDEO_LARGE_KEY) === '1'; } catch { return false; } };
@@ -6525,16 +6914,28 @@ export function messagesFeature(ctx) {
// Data Saver on, or a slow link: the connection to be careful with
const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } };
const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs
- function videoHeavy(url) {
- if (tappedClips.has(url) || largeVideosOk()) return false;
+ // A clip too big to play as it is: over the budget, past 1080p, its index
+ // at the end of a big file — or (known) unmeasurable. Such a clip plays as
+ // an HLS stream where it can (playSrc); else it waits for a tap.
+ function bigClip(url, known = false) {
const d = videoInfo.get(url) || {};
if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true;
- if (!d.size) return true; // nothing known: don't spend data blind
+ if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind
if (d.size > videoBudget()) return true;
// the whole file before the first frame: only worth it for a small one
if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true;
return false;
}
+ function videoHeavy(url) {
+ if (tappedClips.has(url) || largeVideosOk()) return false;
+ const d = videoInfo.get(url) || {};
+ if (d.stream) return false; // a stream takes the rendition the connection carries
+ // one that streams costs what the connection carries: it plays like any
+ // other (Adam, 2026-10-18) — but not over Data Saver or a slow link,
+ // where even a stream is the reader's call
+ if (streamsHere(url) && !frugalData()) return false;
+ return bigClip(url);
+ }
const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
function heavyLabel(url) {
const d = videoInfo.get(url) || {};
@@ -6546,7 +6947,7 @@ export function messagesFeature(ctx) {
if (!url || mediaReady.has(url)) return Promise.resolve();
const known = videoDims.get(url) || dim;
if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); }
- if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve();
+ if (typeof document === 'undefined' || videoUnknown.has(url) || clipGone(url)) return Promise.resolve(); // (gone: nothing to measure)
if (mediaWarming.has(url)) return mediaWarming.get(url);
// the 64 KB probe first: it reads the shape from the file's own header
// and costs nothing like a player's metadata load of an index-last file
@@ -6679,12 +7080,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, ''));
@@ -6702,7 +7112,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.
@@ -6711,6 +7121,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) {
@@ -6720,7 +7133,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;
}
@@ -6847,10 +7260,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);
@@ -6865,7 +7278,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();
@@ -6876,9 +7289,10 @@ 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;
+ if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find
// Cached rows can still be warming. This new row is already prepared
// and can take its stable presentation without waiting for that page.
if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); }
@@ -6939,7 +7353,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;
@@ -6960,7 +7374,7 @@ export function messagesFeature(ctx) {
// The pill's tap: the posts are already in, so this just goes up to them —
// to the OLDEST of them, the one right above where you were reading, so
- // you read on upward through the rest. Reaching the top by yourself clears
+ // you read on upward through the rest. Starting to scroll up by yourself clears
// the pill too (see the scroll listener).
// Drag the new-posts pill any way but up and let go past a short
// distance: it flies off that way and onDismiss runs. A short drag springs
@@ -7126,23 +7540,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 }) => {
@@ -7155,7 +7571,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);
@@ -7356,22 +7772,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 {} }
@@ -7385,43 +7802,99 @@ 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 (mediaMode === 'live') return;
+ 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();
@@ -7451,7 +7924,7 @@ export function messagesFeature(ctx) {
// A bare npub/nprofile — with or without an @ in front, no nostr: prefix —
// is how Vector (and people) write a mention; it resolves to the name too.
const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])';
- const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
+ const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
@@ -7513,13 +7986,13 @@ export function messagesFeature(ctx) {
// known, never taller than the screen; only a clip taller than that sits
// in a black frame, rather than in a strip at the left.
const videoBoxStyle = (d) => d && d.width && d.height
- ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain`
+ ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain`
: 'width:100%;aspect-ratio:16/9;object-fit:contain';
// Over a clip that waits for a tap: what it costs, and the tap that plays
// it (with sound — the reader asked for this one). The box is left alone
// by the morph from here, or a repaint would take the cover off.
function heavyCover(box, url, onPlay) {
- if (box.querySelector('.vid-heavy')) return;
+ if (box.querySelector('.vid-heavy') || box.classList.contains('gone')) return;
box._skipMorph = true;
const cover = h('button', { class: 'vid-heavy', type: 'button', 'aria-label': t('videoTapToPlay'),
onClick: (e) => {
@@ -7531,7 +8004,7 @@ export function messagesFeature(ctx) {
if (v) {
if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url));
v.preload = 'auto';
- v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; });
+ v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); });
}
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
@@ -7545,9 +8018,13 @@ 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)
+ const deadTiles = new Set(); // grid pictures that wouldn't load (this session): left out
// A picture from a host that won't hand over its bytes (no CORS: primal's
// blossom, nostrcheck, most personal sites) can't have its column copy made
// here, so the server makes it (pic.webp: 1100px wide, or a 360px square
@@ -7593,17 +8070,60 @@ export function messagesFeature(ctx) {
const canHevc = () => hevcPlays ??= (() => {
try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; }
})();
- // what a player for this clip loads: the copy, once the original is known
- // not to show here
- const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url)
- && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
+ // A long clip (or a big one whose length isn't known) plays as HLS from
+ // vmedia: a 360p and a 720p rendition, the player taking the one the
+ // connection carries, in 4-second pieces made as they're watched — not a
+ // whole file streamed at its own bitrate (Adam, 2026-10-18; jb55: "you
+ // should use hls (m3u8 ext) for long videos"). Where the browser plays HLS
+ // itself (Chrome since 2025, Safari, Android); elsewhere (Firefox) the
+ // original as before.
+ const HLS_MIN_S = 90, HLS_MIN_BYTES = 60e6;
+ const hlsOf = (url) => VMEDIA + 'hls.m3u8?u=' + encodeURIComponent(url);
+ const isHls = (src) => (src || '').startsWith(VMEDIA + 'hls') || /\.m3u8(\?|#|$)/i.test(src || '');
+ const hlsFailed = new Set();
+ let hlsPlays;
+ const canHls = () => hlsPlays ??= (() => {
+ try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; }
+ })();
+ const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; };
+ // Long, or too big to play as it is (a 4K clip, a 140 MB one: these
+ // waited for a tap with their cost on them) — streamed at a rendition the
+ // connection carries instead. A small short clip plays as it is: one
+ // request, its own quality, no work for the server.
+ const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true));
+ // what a player for this clip loads: the stream for a long or big one;
+ // the copy, once the original is known not to show here
+ const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url
+ : streamsHere(url) ? hlsOf(url)
+ : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url;
+ // A clip whose file its host no longer has: the player is the first to
+ // know (Primal's blossom answers a 404 with no CORS headers, so the
+ // probe couldn't read it and the box sat as a dead "Tap to play" — a
+ // post whose video "won't play", Adam, 2026-11-01). It says so where the
+ // player stood, with the file's link, and the grid leaves the clip out
+ // for a day, as it does a 404 the probe could read. The copy is tried
+ // first: a file the browser can't decode fails the same way, and the
+ // copy plays that one.
+ function clipDead(v, url) {
+ noteVideoInfo(url, { goneAt: Date.now() });
+ v._gone = true;
+ v.controls = false;
+ const box = v.parentElement;
+ if (!box) return;
+ box._skipMorph = true;
+ box.classList.add('gone');
+ for (const el of box.querySelectorAll('.vid-heavy, .vid-pp, .vid-sound')) el.remove();
+ if (!box.querySelector('.vid-nopic')) box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoGone')),
+ h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile'))));
+ }
// A player whose clip turns out to have no picture it can show (its
// metadata says zero width) switches to the copy; if the copy fails too,
// the original's sound plays under a line saying why, with the file.
+ // One whose original won't load at all tries the copy too; the copy
+ // failing as well means the file is gone (clipDead).
function guardPicture(v, url) {
- if (!vmediaOk(url)) return;
const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA);
- v.addEventListener('loadedmetadata', () => {
+ if (vmediaOk(url)) v.addEventListener('loadedmetadata', () => {
if (v.videoWidth || onCopy() || h264Failed.has(url)) return;
noPicture.add(url);
const want = !v.paused;
@@ -7611,8 +8131,28 @@ export function messagesFeature(ctx) {
if (want) v.play().catch(() => {});
});
v.addEventListener('error', () => {
- if (!onCopy()) return;
+ if (!v.getAttribute('src')) return; // let go of (scrolled off), not failed
+ if (!onCopy()) {
+ if (vmediaOk(url) && !h264Failed.has(url) && !v._origFailed) {
+ v._origFailed = true;
+ const want = !v.paused;
+ v.setAttribute('src', h264Of(url));
+ if (want) v.play().catch(() => {});
+ return;
+ }
+ clipDead(v, url);
+ return;
+ }
+ // the stream failed: the copy or the original, as it would have been
+ if (isHls(v.getAttribute('src'))) {
+ hlsFailed.add(url);
+ const want = !v.paused;
+ v.setAttribute('src', playSrc(url));
+ if (want) v.play().catch(() => {});
+ return;
+ }
h264Failed.add(url);
+ if (v._origFailed) { clipDead(v, url); return; } // neither the file nor a copy of it: gone
const want = !v.paused;
v.setAttribute('src', url);
if (want) v.play().catch(() => {});
@@ -7625,10 +8165,38 @@ export function messagesFeature(ctx) {
});
}
// A tap on the picture itself (not the player's own play button or its
- // control bar): inline, it goes full screen — the corner button is a
+ // control bar): inline, a mouse click pauses a playing clip; a paused one
+ // (or any touch) goes full screen — the corner button is a
// reach on a phone. Full screen, it pauses a playing clip, and a tap on a
// paused one comes back out. The player's controls are inside the video
// element, so where they sit is judged from the tap's position.
+ // The big play/pause over a feed clip is for a mouse: <html> carries
+ // .has-mouse while the last pointer was one (a touch laptop switches with
+ // the hand), and CSS shows the button on :hover (a repaint can't drop it).
+ if (typeof document !== 'undefined') {
+ const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on);
+ document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true });
+ document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true });
+ }
+ // A clip whose post has timed reactions seeks on the moments bar under it
+ // (the reactions sit on it at their moments), so the player's own controls
+ // — a second seek bar — stay off; the middle button and the sound button
+ // are its controls then (Adam: "hide the main seek bar … and only show a
+ // moments seek bar"). Otherwise the player's controls, once it has woken.
+ const nativeControls = (v) => { v.controls = !v._ownBar && !v._gone; };
+ function ownBar(player, on) {
+ const box = player.closest('.note-video-box');
+ if (!box || player._ownBar === on) return;
+ player._ownBar = on;
+ box.classList.toggle('own-bar', on);
+ if (!box.classList.contains('idle')) nativeControls(player);
+ // with no player controls, the sound button stays to turn it back down
+ if (on && !box.querySelector('.vid-sound')) {
+ const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); });
+ b.setSound(!player.muted);
+ box.append(b);
+ }
+ }
const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null;
let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened
function videoTap(e, url) {
@@ -7650,12 +8218,24 @@ export function messagesFeature(ctx) {
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return true;
}
+ // a mouse click on a playing clip just pauses it (Adam: "clicking on a
+ // video that's playing should pause the video, not full screen it"); it
+ // keeps its controls and the middle button resumes it, and a click on it
+ // paused opens the pager. A touch has no middle button: its tap still opens.
+ if (!v.paused && e.pointerType === 'mouse') {
+ v.pause();
+ wokeClips.add(url);
+ const box = v.closest('.note-video-box');
+ if (box) { box.classList.remove('idle'); box._skipMorph = true; }
+ nativeControls(v);
+ return true;
+ }
if (openClip(v, url)) return true;
// a clip still idle (autoplaying, no controls) becomes the reader's
wokeClips.add(url);
const box = v.closest('.note-video-box');
if (box) { box.classList.remove('idle'); box._skipMorph = true; }
- v.controls = true;
+ nativeControls(v);
const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen;
if (!go) return true;
// (the screen isn't turned for it: a wide clip plays with bars, and the
@@ -7668,7 +8248,9 @@ export function messagesFeature(ctx) {
// The player's own full-screen button on a feed clip: the pager, not
// Chrome's full screen (no next clip there). The button stays on the bar
// (left off, Chrome greys it out) and is taken over here.
- if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip) {
+ // (only a clip the pager has: taken out of full screen for a pager that
+ // then wouldn't open, a quoted post's clip went nowhere at all)
+ if (el && !pager && el.matches && el.matches('.notes-feed video.note-video') && el.dataset.clip && clipItem(el, el.dataset.clip)) {
const url = el.dataset.clip;
Promise.resolve((document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)).catch(() => {})
.then(() => { if (!pager && document.contains(el)) openClip(el, url); });
@@ -7693,10 +8275,9 @@ export function messagesFeature(ctx) {
preload: 'none', playsinline: true, muted: loud ? undefined : true,
style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
// learned now, used by the next paint of any row with this clip
- onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
+ onLoadedmetadata: (e) => { rememberVideo(url, e.target.videoWidth, e.target.videoHeight); attachMediaTimeline(e.target, url); },
// a touch's tap was taken at pointerup (below): its click, if one comes, is spent
- onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); },
- onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
+ onClick: (e) => { if (Date.now() - (e.currentTarget._tapAt || 0) < 800) { e.stopPropagation(); e.preventDefault(); return; } videoTap(e, url); } });
guardPicture(v, url);
// With its own controls up, Chrome on Android keeps a tap on the clip for
// them (it shows or hides the bar) and the page never gets the click — so
@@ -7715,13 +8296,38 @@ export function messagesFeature(ctx) {
if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now();
});
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
- const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v);
+ // a tall clip is held to the cap's height and the box narrows to its
+ // shape, instead of a full-width box of black bars (Adam, on a 1920 screen)
+ const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined;
+ const known = feedMedia(url);
+ const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v);
+ v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; });
if (loud) box._skipMorph = true;
+ // With a mouse: a big play/pause button in the middle while the pointer is
+ // over the clip. It plays or pauses in place, never full screen (a click
+ // elsewhere on a paused clip opens the pager). Touch keeps its taps.
+ const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => {
+ e.stopPropagation(); e.preventDefault();
+ const player = e.currentTarget.closest('.note-video-box')?.querySelector('video');
+ if (!player) return;
+ if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause();
+ } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' }));
+ const ppState = () => {
+ const live = v.closest('.note-video-box');
+ const b = live && live.querySelector('.vid-pp');
+ if (!b) return;
+ b.classList.toggle('playing', !v.paused);
+ b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause'));
+ };
+ v.addEventListener('play', ppState);
+ v.addEventListener('pause', ppState);
+ box.append(pp);
+
// remembered by clip, so a repaint draws the box as it now stands
const wake = () => {
wokeClips.add(url);
box.classList.remove('idle');
- v.controls = true;
+ nativeControls(v);
};
v.addEventListener('playing', wake);
const btn = loud ? null : soundBtn((button) => {
@@ -7730,14 +8336,19 @@ export function messagesFeature(ctx) {
const liveBox = button.closest('.note-video-box');
const player = liveBox?.querySelector('video');
if (!player) return;
+ if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; }
player.muted = false;
unmutedClips.add(url);
liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted
if (player.paused) player.play().catch(() => {});
- button.remove(); // the player's own controls carry mute from here
+ // the player's own controls carry mute from here — unless they're off
+ // for the moments bar: then this button turns it down again
+ if (player._ownBar) button.setSound(true); else button.remove();
});
v.onvolumechange = () => {
- if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same
+ const b = v.closest('.note-video-box')?.querySelector('.vid-sound');
+ if (v._ownBar && b && b.setSound) b.setSound(!v.muted);
+ if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same
else unmutedClips.delete(url); // muted again from the control: the next box starts quiet
};
if (btn) box.append(btn);
@@ -7749,6 +8360,7 @@ export function messagesFeature(ctx) {
() => {
box._inView = true;
clearTimeout(box._release);
+ if (v._gone) return; // nothing to load, and nothing to ask
if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) {
v.setAttribute('src', playSrc(url));
v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true });
@@ -7777,6 +8389,9 @@ export function messagesFeature(ctx) {
try { v.removeAttribute('src'); v.load(); } catch {}
}, 2500);
});
+ // known gone already (this player, or one before it, found the file
+ // missing): said at once, no request made
+ if (clipGone(url)) clipDead(v, url);
return box;
}
@@ -7891,8 +8506,10 @@ export function messagesFeature(ctx) {
// dozen relays a fresh REQ for each quote it met).
const findQ = new Map(); // relay -> Map(id -> waiters)
let findTimer = null;
- function askNote(relays, id) {
- relays = readRelayList(relays);
+ function askNote(relays, id, hints = []) {
+ // (a relay the reference itself names is asked even when this device has
+ // marked it sick: it's the strongest clue there is to where the note lives)
+ relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])];
if (!relays.length) return Promise.resolve(null);
return new Promise((resolve) => {
const w = { left: relays.length, resolve, done: false };
@@ -7929,9 +8546,10 @@ export function messagesFeature(ctx) {
function findNote(ref) {
const ask = (relays) => askNote(relays, ref.id);
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
+ const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))];
const asks = [
- ask(near),
- ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
+ askNote(near, ref.id, ref.relays || []),
+ ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))),
ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
];
return new Promise((resolve) => {
@@ -7942,7 +8560,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;
@@ -8046,6 +8668,7 @@ export function messagesFeature(ctx) {
if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c });
}
const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND;
+ if (c.ev && ref.kind === LIVE_KIND && !hiddenPk(liveHost(c.ev))) return liveCard(c.ev);
if (!c.ev && isList) {
// a list needs nothing fetched to open: the feed resolves its members
return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } },
@@ -8115,6 +8738,1463 @@ export function messagesFeature(ctx) {
return out;
}
+ // ---- live streams (NIP-53) -----------------------------------------------
+ // What's on now, newest copy of each stream by its address: asked of the
+ // relays streams are announced on every couple of minutes while a feed or
+ // profile wants it. A feed shows the ones its people are in (Following:
+ // your follows; Everything and Popular: all of them) in a row above its
+ // posts; a profile says when its person is on; a post linking a stream
+ // shows it as a card. Any of them opens the stream's own screen.
+ const liveSeen = new Map(); // addr -> newest 30311
+ let liveAt = 0, liveAsking = null;
+ const liveAskedOf = new Map(); // pk -> when their own streams were asked for
+ function takeLive(evs) {
+ let changed = false;
+ for (const [a, ev] of newestByAddr(evs)) {
+ const cur = liveSeen.get(a);
+ if (!cur || cur.created_at < ev.created_at) { liveSeen.set(a, ev); changed = true; }
+ }
+ if (changed) liveHostsAt = 0;
+ return changed;
+ }
+ // who is on now (host -> their stream), for the rings round faces: worked
+ // out again when a stream changes and every half minute (one goes stale)
+ let liveHostsAt = 0, liveHosts = new Map();
+ // Our own live server knows better than an announcement: a stream it
+ // carries that it says is over (or taken down) is over, whatever its last
+ // 30311 claims — an app closed mid-stream never says `ended`, and the
+ // announcement reads live for an hour. Asked at most every 20 s a stream.
+ const OUR_LIVE = /^(https:\/\/v3\.coinos\.io|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)\/live\/[0-9a-f]{16}\/live\.m3u8$/;
+ const liveServerSays = new Map(); // stats url -> { state, seconds, at, asking }
+ function liveServerState(ev) {
+ const url = liveStream(ev) || '';
+ if (!OUR_LIVE.test(url)) return null;
+ const u = url.replace(/live\.m3u8$/, 'stats');
+ let k = liveServerSays.get(u);
+ if (!k) liveServerSays.set(u, k = { state: null, at: 0, asking: false });
+ if (!k.asking && Date.now() - k.at > 20_000) {
+ k.asking = true;
+ fetch(u, { cache: 'no-store' })
+ .then((r) => (r.status === 404 ? { state: 'ended' } : r.json()))
+ .then((j) => { const was = k.state; k.state = j.state || null; k.seconds = j.seconds; if (was !== k.state) { liveHostsAt = 0; scheduleRepaint(); } })
+ .catch(() => {}).finally(() => { k.asking = false; k.at = Date.now(); });
+ }
+ return k.state;
+ }
+ // taken down by an admin, or deleted by its streamer: nothing to play
+ const serverGone = (ev) => { const st = liveServerState(ev); return st === 'removed' || st === 'deleted'; };
+ const serverOver = (ev) => liveServerState(ev) === 'ended' || serverGone(ev);
+ const liveStatus = (ev, now) => { const st = announcedStatus(ev, now); return st === 'live' && serverOver(ev) ? 'ended' : st; };
+ const isLiveNow = (ev, now) => isLiveAnnounced(ev, now) && !serverOver(ev);
+ // Your own stream announced live that this tab isn't sending (the app was
+ // closed, reloaded, or lost it): end the announcement, its recording kept.
+ async function endAnnounced(ev) {
+ if (!confirm(t('goLiveEndAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'ends', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], ['ends', String(now)], CLIENT_TAG]);
+ const k = liveServerSays.get((liveStream(ev) || '').replace(/live\.m3u8$/, 'stats'));
+ if (OUR_LIVE.test(liveStream(ev)) && k && k.state !== 'removed' && k.state !== 'deleted' && (k.seconds || 0) >= 6) tags.push(['recording', liveStream(ev).replace(/live\.m3u8$/, 'index.m3u8')]);
+ const partial = { kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ liveHostsAt = 0;
+ render();
+ await publishOn([...new Set([...LIVE_RELAYS, ...liveRelays(ev), ...wallet.nostrRelays()])], evt);
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // ---- moderation: a viewer reports a stream we carry; an admin takes it
+ // down (the server ends it and keeps it from everyone, for reporting)
+ const LIVE_ADMIN = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';
+ const ourStreamId = (ev) => { const m = OUR_LIVE.test(liveStream(ev) || '') && /\/live\/([0-9a-f]{16})\/live\.m3u8$/.exec(liveStream(ev)); return m ? m[1] : null; };
+ async function livePost(path, body) {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + path], ['method', 'POST']] });
+ const r = await fetch(liveBase() + path, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(10000) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status);
+ return j;
+ }
+ const REPORT_REASONS = ['csam', 'sexual', 'violence', 'illegal', 'spam', 'other'];
+ async function sendLiveReport(ev) {
+ const r = ui.liveReport;
+ if (!r || !r.reason) return;
+ r.busy = true; render();
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/report', { reason: r.reason, text: (r.text || '').trim() });
+ ui.liveReport = null;
+ toast(t('liveReported'));
+ } catch (e) { r.busy = false; toast(e.message || String(e)); }
+ render();
+ }
+ function liveReportPanel(ev) {
+ const r = ui.liveReport;
+ return h('div', { class: 'card col live-report', style: 'gap:8px', 'data-key': 'live-report' },
+ h('strong', {}, t('liveReportTitle')),
+ h('div', { class: 'small muted' }, t('liveReportDesc')),
+ ...REPORT_REASONS.map((x) => h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('input', { type: 'radio', name: 'live-report-reason', value: x, checked: r.reason === x, onChange: () => { r.reason = x; render(); } }),
+ h('span', {}, t('liveReason_' + x)))),
+ h('textarea', { class: 'live-report-text', rows: '2', maxlength: '500', placeholder: t('liveReportTextPh'), value: r.text || '', onInput: (e) => { r.text = e.target.value; } }),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-primary btn-sm live-report-send', disabled: !r.reason || !!r.busy, onClick: () => sendLiveReport(ev) }, r.busy ? h('span', { class: 'spinner sm' }) : t('liveReportSend')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.liveReport = null; render(); } }, t('cancel'))));
+ }
+ async function takeDown(ev) {
+ if (!confirm(t('liveTakeDownAsk'))) return;
+ const ban = confirm(t('liveBanAsk'));
+ try {
+ await livePost('/live/' + ourStreamId(ev) + '/remove', { ban });
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'removed'; k.at = Date.now(); }
+ toast(t(ban ? 'liveTakenDownBanned' : 'liveTakenDown'));
+ render();
+ } catch (e) { toast(e.message || String(e)); }
+ }
+ // Your own stream's recording, deleted: the server lets its files go, and the
+ // announcement is said again without it (other apps drop the link too).
+ async function deleteRecording(ev) {
+ if (!confirm(t('liveDeleteAsk'))) return;
+ try {
+ const id = await requireIdentity();
+ if (id.pubkey !== ev.pubkey) { toast(t('liveNotYours')); return; }
+ await livePost('/live/' + ourStreamId(ev) + '/delete', {});
+ const k = liveServerSays.get(liveStream(ev).replace(/live\.m3u8$/, 'stats'));
+ if (k) { k.state = 'deleted'; k.at = Date.now(); }
+ const now = Math.floor(Date.now() / 1000);
+ const tags = ev.tags.filter((x) => !['status', 'current_participants', 'recording', 'client'].includes(x[0]))
+ .concat([['status', 'ended'], CLIENT_TAG]);
+ if (!tags.some((x) => x[0] === 'ends')) tags.push(['ends', String(now)]);
+ const evt = await signAsMe({ kind: LIVE_KIND, content: ev.content || '', created_at: Math.max(now, ev.created_at + 1), tags });
+ takeLive([evt]);
+ liveHostsAt = 0;
+ publishOn([...new Set([...liveRelays(ev), ...LIVE_RELAYS, ...wallet.nostrRelays()])], evt).catch(() => {});
+ toast(t('liveDeleted'));
+ render();
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); }
+ }
+ function liveHostNow(pk) {
+ if (!liveSeen.size) return null;
+ if (Date.now() - liveHostsAt > 30_000) {
+ liveHostsAt = Date.now();
+ const now = Date.now() / 1000;
+ liveHosts = new Map();
+ for (const ev of liveSeen.values()) {
+ if (!isLiveNow(ev, now) || !liveShowable(ev)) continue;
+ const h = liveHost(ev), cur = liveHosts.get(h);
+ if (!cur || cur.created_at < ev.created_at) liveHosts.set(h, ev);
+ }
+ }
+ return liveHosts.get(pk) || null;
+ }
+ const liveSince = () => Math.floor(Date.now() / 1000) - 6 * 3600;
+ function refreshLive() {
+ if (liveAsking || Date.now() - liveAt < 120_000) return;
+ liveAt = Date.now();
+ liveAsking = queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: liveSince(), limit: 300 }, 4000)
+ .then((evs) => { if (takeLive(evs)) scheduleRepaint(); })
+ .catch(() => {}).finally(() => { liveAsking = null; });
+ }
+ // the streams someone is in, asked of their own relays as well (a
+ // streamer's on a relay we don't otherwise read)
+ function askLiveOf(pk) {
+ if (Date.now() - (liveAskedOf.get(pk) || 0) < 120_000) return;
+ liveAskedOf.set(pk, Date.now());
+ Promise.resolve(relaysOf(pk)).catch(() => []).then((theirs) => {
+ const relays = [...new Set([...LIVE_RELAYS, ...(theirs || []).slice(0, 4)])];
+ return Promise.all([
+ queryOn(relays, { kinds: [LIVE_KIND], '#p': [pk], since: pastSince(), limit: 30 }, 4000),
+ queryOn(relays, { kinds: [LIVE_KIND], authors: [pk], since: pastSince(), limit: 30 }, 4000),
+ ]);
+ }).then(([a, b]) => { if (takeLive([...a, ...b])) scheduleRepaint(); }).catch(() => {});
+ }
+ // Past streams: ended ones that left a recording, the last month of them.
+ // A feed asks for its people's (all of them for Everything), a profile for
+ // its person's; opening one plays the recording (livePlayable).
+ const PAST_DAYS = 30;
+ const pastSince = () => Math.floor(Date.now() / 1000) - PAST_DAYS * 86400;
+ const pastAsked = new Map(); // what was asked -> when
+ let pastAsking = 0;
+ function askPast(key, filters) {
+ if (Date.now() - (pastAsked.get(key) || 0) < 300_000) return;
+ pastAsked.set(key, Date.now());
+ pastAsking++;
+ Promise.all(filters.map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], since: pastSince(), ...f }, 5000).catch(() => [])))
+ .then((rs) => { if (takeLive(rs.flat())) scheduleRepaint(); })
+ .finally(() => { pastAsking--; scheduleRepaint(); });
+ }
+ const isPast = (ev) => liveStatus(ev) === 'ended' && !!liveRecording(ev) && !serverGone(ev);
+ const liveEndedAt = (ev) => +(ev.tags.find((x) => x[0] === 'ends') || [])[1] || ev.created_at;
+ const pastPeople = (def) => {
+ const fol = followsNow().set;
+ const people = def.all ? null : [...new Set([...feedAuthors(def), ...(def.follows ? fol : [])])];
+ return people && people.slice(0, 400);
+ };
+ const pastFilters = (ps, limit) => (ps ? [{ authors: ps, limit }, { '#p': ps, limit }] : [{ limit }]);
+ function pastForFeed(def) {
+ const ps = pastPeople(def);
+ if (ps && !ps.length) return [];
+ askPast('feed:' + def.id, pastFilters(ps, ps ? 200 : 300));
+ const set = ps && new Set(ps);
+ return [...liveSeen.values()]
+ .filter((ev) => isPast(ev) && liveShowable(ev) && !liveNsfw(ev) && (!set || set.has(liveHost(ev)) || set.has(ev.pubkey)))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ }
+ const pastOf = (pk) => [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isPast(ev) && liveShowable(ev))
+ .sort((a, b) => liveEndedAt(b) - liveEndedAt(a));
+ const liveNsfw = (ev) => ev.tags.some((x) => x[0] === 'content-warning' || (x[0] === 't' && /^nsfw$/i.test(x[1] || '')));
+ const liveShowable = (ev) => !hiddenPk(liveHost(ev)) && !hiddenPk(ev.pubkey);
+ function liveNowOf(pk) {
+ const now = Date.now() / 1000;
+ return [...liveSeen.values()].filter((ev) => liveHost(ev) === pk && isLiveNow(ev, now) && liveShowable(ev))
+ .sort((a, b) => b.created_at - a.created_at)[0] || null;
+ }
+ function liveForFeed(def) {
+ const now = Date.now() / 1000;
+ const fol = followsNow().set;
+ const people = def.all ? null : new Set([...feedAuthors(def), ...(def.follows ? fol : [])]);
+ if (people && !people.size) return [];
+ return [...liveSeen.values()]
+ .filter((ev) => isLiveNow(ev, now) && liveShowable(ev) && !liveNsfw(ev) && (!people || people.has(liveHost(ev)) || people.has(ev.pubkey)))
+ .sort((a, b) => (fol.has(liveHost(b)) - fol.has(liveHost(a))) || ((liveViewers(b) || 0) - (liveViewers(a) || 0)));
+ }
+ const liveCoverStyle = (ev) => { const img = liveImage(ev); return img ? 'background-image:url(' + JSON.stringify(img) + ')' : undefined; };
+ function liveBadge(ev) {
+ const s = liveStatus(ev);
+ return h('span', { class: 'live-badge ' + s }, t(s === 'live' ? 'liveBadge' : s === 'planned' ? 'livePlanned' : 'liveEnded'));
+ }
+ // Past streams scroll on: more tiles as the grid's foot comes near, and
+ // once those we hold run out, older ones from the relays (before the oldest
+ // asked so far) until a page brings nothing back.
+ const PAST_STEP = 24;
+ const pastPages = new Map(); // feed id -> { shown, until, end, busy }
+ const pastPage = (def) => { let p = pastPages.get(def.id); if (!p) pastPages.set(def.id, p = { shown: PAST_STEP, until: null, end: false, busy: false }); return p; };
+ function morePast() {
+ if (mediaMode !== 'live' || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread || ui.liveStream) return;
+ const g = document.querySelector('.live-past') || document.querySelector('[data-key="live-past-wait"]');
+ if (!g || g.getBoundingClientRect().bottom > window.innerHeight * 2) return;
+ const def = feedDef(), p = pastPage(def), all = pastForFeed(def);
+ if (p.shown < all.length) { p.shown += PAST_STEP; scheduleRepaint(); return; }
+ if (p.end || p.busy || pastAsking) return;
+ const ps = pastPeople(def);
+ if (ps && !ps.length) { p.end = true; return; }
+ // older than the oldest we hold (the first ask's page may stop short of its month)
+ if (p.until == null) p.until = Math.min(Math.floor(Date.now() / 1000), ...all.map((e) => e.created_at));
+ p.busy = true;
+ scheduleRepaint();
+ Promise.all(pastFilters(ps, 100).map((f) => queryOn(LIVE_RELAYS, { kinds: [LIVE_KIND], until: p.until - 1, ...f }, 5000).catch(() => [])))
+ .then((rs) => {
+ const evs = rs.flat();
+ if (!evs.length) { p.end = true; return; }
+ p.until = Math.min(...evs.map((e) => e.created_at));
+ takeLive(evs);
+ p.shown += PAST_STEP;
+ })
+ .finally(() => { p.busy = false; scheduleRepaint(); setTimeout(morePast, 300); }); // still short of the screen: on
+ }
+ let pastScrollQueued = false;
+ if (typeof document !== 'undefined') document.addEventListener('scroll', () => {
+ if (pastScrollQueued || mediaMode !== 'live') return;
+ pastScrollQueued = true;
+ requestAnimationFrame(() => { pastScrollQueued = false; morePast(); });
+ }, { capture: true, passive: true });
+ // the feed's Live chip: its streams on now, as a grid of tiles
+ function liveGrid(def) {
+ const evs = liveForFeed(def).slice(0, 60);
+ const pp = pastPage(def);
+ const past = pastForFeed(def).slice(0, pp.shown);
+ setTimeout(morePast, 0); // a short grid fills the screen without a scroll
+ return h('div', { class: 'col', style: 'gap:12px', 'data-key': 'live-view' },
+ evs.length ? h('div', { class: 'live-grid', 'data-key': 'live-grid' }, ...evs.map(liveTile))
+ : liveAsking ? h('div', { class: 'row gap6', 'data-key': 'live-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
+ : h('div', { class: 'small faint', 'data-key': 'live-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoLive')),
+ past.length ? h('h4', { class: 'live-past-title', 'data-key': 'live-past-title' }, t('livePast')) : null,
+ past.length ? h('div', { class: 'live-grid live-past', 'data-key': 'live-past' }, ...past.map(liveTile)) : null,
+ pastAsking || pp.busy ? h('div', { class: 'row gap6', 'data-key': 'live-past-wait', style: 'justify-content:center;padding:8px 0' }, h('span', { class: 'spinner sm' })) : null);
+ }
+ // a stream's tile: on now (how many watch) or past (when it ended)
+ function liveTile(ev) {
+ const host = liveHost(ev), n = liveViewers(ev), now = isLiveNow(ev);
+ return h('button', { type: 'button', class: 'live-tile', 'data-key': 'live:' + liveAddr(ev), onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) },
+ now ? liveBadge(ev) : h('span', { class: 'live-badge' }, t('liveRecordingBadge')),
+ now ? (n != null ? h('span', { class: 'live-viewers' }, t('liveWatching', { n })) : null)
+ : h('span', { class: 'live-viewers' }, timeLabel(liveEndedAt(ev) * 1000))),
+ h('div', { class: 'live-tile-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6 live-tile-host' }, avatar(host, 'chat-avatar mini', false), h('span', { class: 'small muted' }, displayName(host))));
+ }
+ // a profile's Streams tab: their past streams, as the Live chip's tiles
+ function pastStreams(pk) {
+ return h('div', { class: 'live-grid live-past', 'data-key': 'live-past-of:' + pk }, ...pastOf(pk).slice(0, 60).map(liveTile));
+ }
+ // a profile's "on now" line
+ function liveBanner(pk) {
+ askLiveOf(pk);
+ const ev = liveNowOf(pk);
+ if (!ev) {
+ // yours, still announced live, but nothing's going out: say so, and End it
+ const stale = pk === mePk() && !(bc && bc.phase === 'live') && [...liveSeen.values()]
+ .find((x) => x.pubkey === pk && liveHost(x) === pk && isLiveAnnounced(x) && serverOver(x));
+ return stale ? h('div', { class: 'live-banner', 'data-key': 'live-stale:' + liveAddr(stale) },
+ h('span', { class: 'live-banner-title' }, t('liveStaleMine', { title: liveTitle(stale) || t('liveUntitled') })),
+ h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(stale) }, t('goLiveEnd'))) : null;
+ }
+ return h('button', { type: 'button', class: 'live-banner', 'data-key': 'live-banner:' + liveAddr(ev), onClick: () => openLive(ev) },
+ h('span', { class: 'live-badge live' }, t('liveBadge')),
+ h('span', { class: 'live-banner-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('span', { class: 'small muted' }, t('liveWatch')));
+ }
+ // a post linking a stream (nostr:naddr of a 30311)
+ function liveCard(ev) {
+ const host = liveHost(ev);
+ return h('div', { class: 'quote-card clickable live-card', onClick: (e) => { e.stopPropagation(); openLive(ev); } },
+ h('div', { class: 'live-cover', style: liveCoverStyle(ev) }, liveBadge(ev)),
+ h('div', { class: 'col', style: 'gap:4px;min-width:0' },
+ h('div', { class: 'addr-title' }, liveTitle(ev) || t('liveUntitled')),
+ h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
+ avatar(host, 'chat-avatar mini', false), h('span', { class: 'quote-name' }, displayName(host)))));
+ }
+
+ function openLive(ev) {
+ rememberPlace('live');
+ const a = liveAddr(ev);
+ takeLive([ev]);
+ setZapAddr(ev.id, a);
+ ui.liveStream = { a, seed: ev };
+ ui.noteThread = null; ui.profilePk = null; ui.profOverThread = false; ui.userSearch = null;
+ render();
+ }
+ // a link to a stream (/naddr1…, nostr:naddr in a post we couldn't show)
+ async function openLiveRef(ref) {
+ const ev = liveSeen.get(LIVE_KIND + ':' + ref.pk + ':' + ref.d) || await findAddr(ref).catch(() => null);
+ if (ev) openLive(ev);
+ else toast(t('liveNotFound'));
+ }
+
+ // The open stream: its chat (1311) and zaps (9735, and coinos's Ark
+ // receipts) as they come, and the stream event itself as it's refreshed
+ // (the viewer count, `ended`).
+ let liveWatch = null;
+ function watchLive(s) {
+ if (liveWatch && liveWatch.a === s.a) return liveWatch;
+ stopLiveWatch();
+ const [, pk, ...rest] = s.a.split(':');
+ const d = rest.join(':');
+ takeLive([s.seed]);
+ const w = liveWatch = { a: s.a, lines: new Map(), stops: [], video: null, hls: null, err: null, actions: new Map(), actionTimer: null, actionStop: null, actionKey: '', sheet: null, reply: null, reacting: new Set() };
+ let mine = []; try { mine = wallet.nostrRelays(); } catch {}
+ const relays = w.relays = [...new Set([...liveRelays(s.seed), ...mine])];
+ const add = (e) => {
+ if (liveWatch !== w || w.lines.has(e.id)) return;
+ if (e.kind === 7 || e.kind === 5) { takeLiveAction(w, e); return; }
+ w.lines.set(e.id, e);
+ if (w.lines.size > 200) {
+ const oldest = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at)[0];
+ w.lines.delete(oldest.id);
+ }
+ if (e.kind === LIVE_CHAT_KIND) { setZapAddr(e.id, w.a); watchLiveActions(w); }
+ else noteReceipt(e);
+ scheduleRepaint();
+ };
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
+ w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
+ w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ // who's here: NIP-53 presence (kind 10312, the stream in its a tag) —
+ // Shosho, zap.stream and others say it, and so do we while it's open
+ w.here = new Map(); // pubkey -> when they last said so
+ w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => {
+ if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return;
+ w.here.set(e.pubkey, e.created_at);
+ scheduleRepaint();
+ }));
+ sayHere(w);
+ return w;
+ }
+ // Presence counts for half an hour (some clients say it once, on joining);
+ // ours is said again every five minutes while the stream is open, and
+ // taken back (an empty one) on leaving. Only with a key held here: a
+ // signer app would be asked to sign every five minutes.
+ const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800;
+ async function presence(w, here) {
+ const id = await identity().catch(() => null);
+ if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return;
+ const ev = liveSeen.get(w.a);
+ if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over
+ const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer);
+ if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; }
+ publishOn(w.relays, evt).catch(() => {});
+ }
+ function sayHere(w) {
+ presence(w, true);
+ w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000);
+ }
+ // the people known to be watching (fresh presence), newest first
+ const watchersOf = (w) => {
+ const since = Date.now() / 1000 - PRESENCE_FRESH;
+ return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : [];
+ };
+ // "4 watching": hover names who (as many as are known), a tap shows their faces
+ function watchingLine(w, n, cls = 'small muted') {
+ const who = watchersOf(w);
+ const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : '');
+ return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined,
+ role: who.length ? 'button' : undefined,
+ onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined },
+ t('liveWatching', { n: Math.max(n || 0, who.length) }));
+ }
+ function watchersRow(w) {
+ const who = watchersOf(w);
+ if (!w || !w.showWho || !who.length) return null;
+ return h('div', { class: 'live-who', 'data-key': 'live-who' },
+ ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) },
+ avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk)))));
+ }
+ // Include reactions/receipts from clients that only tag the comment's e,
+ // plus deletions of known reactions. Batch changes into one subscription.
+ function watchLiveActions(w) {
+ if (w.actionTimer || liveWatch !== w) return;
+ w.actionTimer = setTimeout(() => {
+ w.actionTimer = null;
+ if (liveWatch !== w) return;
+ const ids = [...w.lines.values()].filter(e => e.kind === LIVE_CHAT_KIND).map(e => e.id);
+ const reactions = [...w.actions.values()].filter(e => e.kind === 7 && ids.includes(e.tags.filter(t => t[0] === 'e').at(-1)?.[1])).slice(-400).map(e => e.id);
+ const all = [...ids, ...reactions];
+ const key = all.join(',');
+ if (key === w.actionKey) return;
+ w.actionKey = key;
+ if (w.actionStop) w.actionStop();
+ w.actionStop = all.length ? subscribeOn(w.relays, { kinds: [7, 5, 9735, 9737], '#e': all, limit: 1000 }, e => takeLiveAction(w, e)) : null;
+ }, 1000);
+ }
+ function takeLiveAction(w, ev) {
+ if (liveWatch !== w) return;
+ if (ev.kind === 9735 || ev.kind === 9737) { noteReceipt(ev); return; }
+ if (w.actions.has(ev.id)) return;
+ w.actions.set(ev.id, ev);
+ while (w.actions.size > 2000) w.actions.delete(w.actions.keys().next().value);
+ noteEmoji(ev);
+ watchLiveActions(w);
+ scheduleRepaint();
+ }
+ async function sendLiveReaction(w, ev, emoji) {
+ if (w.reacting.has(ev.id)) return;
+ w.reacting.add(ev.id);
+ try {
+ const id = await requireIdentity();
+ const current = liveReactions([...w.actions.values()]).get(ev.id)?.get(id.pubkey);
+ const old = [...w.actions.values()].filter(e => e.kind === 7 && e.pubkey === id.pubkey && e.tags.filter(t => t[0] === 'e').at(-1)?.[1] === ev.id);
+ const sign = partial => id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : id.signer.signEvent(partial);
+ const events = [];
+ const now = old.reduce((n, e) => Math.max(n, e.created_at + 1), Math.floor(Date.now() / 1000));
+ if (old.length) events.push(await sign({ kind: 5, content: '', created_at: now,
+ tags: [['a', w.a], ['k', '7'], ...old.map(e => ['e', e.id])] }));
+ const currentEmoji = current && (!current.content || current.content === '+' ? '❤️' : current.content);
+ if (currentEmoji !== emoji) events.push(await sign({ kind: 7, content: emoji, created_at: now,
+ tags: [['a', w.a], ['e', ev.id, w.relays[0] || ''], ['p', ev.pubkey], ['k', String(LIVE_CHAT_KIND)], ...reactEmojiTags(emoji), CLIENT_TAG] }));
+ if (liveWatch !== w) return;
+ const added = new Set(events.filter(e => !w.actions.has(e.id)).map(e => e.id));
+ for (const e of events) takeLiveAction(w, e);
+ render();
+ const results = await Promise.all(events.map(e => publishOn(w.relays, e).catch(() => false)));
+ // what no relay took is taken back off the screen
+ results.forEach((ok, i) => { if (!ok && added.has(events[i].id)) w.actions.delete(events[i].id); });
+ if (results.some(ok => !ok)) { toast(t('msgSendFailed')); render(); }
+ } catch (e) { if (!(e instanceof NoIdentity)) toast(t('msgSendFailed')); }
+ finally { w.reacting.delete(ev.id); scheduleRepaint(); }
+ }
+ function stopLivePlayer(w = liveWatch) {
+ if (!w) return;
+ if (w.hls) { try { w.hls.destroy(); } catch {} w.hls = null; }
+ if (w.pc) { try { w.pc.close(); } catch {} w.pc = null; }
+ clearInterval(w.rtcBeat);
+ const v = w.video;
+ w.video = null;
+ if (v) { try { v.pause(); v.srcObject = null; v.removeAttribute('src'); v.load(); } catch {} }
+ }
+ // A stream of ours as WebRTC: the live server gets Cloudflare's SFU to offer
+ // the sender's tracks, we answer. Resolves once connected (rejects after
+ // 8 s, or if there's no WebRTC for it); after that, a lost connection or a
+ // new publish (the sender reconnected) mounts the player again.
+ async function rtcWatch(w, el, src, go) {
+ const base = src.replace(/live\.m3u8$/, '');
+ const pc = w.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const media = new MediaStream();
+ pc.ontrack = (e) => {
+ media.addTrack(e.track);
+ if (w.video === el && el.srcObject !== media) { el.srcObject = media; go(); }
+ };
+ const r = await fetch(base + 'rtc/watch', { method: 'POST', signal: AbortSignal.timeout(8000) });
+ if (!r.ok) throw new Error('watch ' + r.status);
+ const j = await r.json();
+ if (w.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'offer', sdp: j.sdp });
+ await pc.setLocalDescription(await pc.createAnswer());
+ const r2 = await fetch(base + 'rtc/answer', { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ session: j.session, sdp: pc.localDescription.sdp }), signal: AbortSignal.timeout(8000) });
+ if (!r2.ok) throw new Error('answer ' + r2.status);
+ await new Promise((resolve, reject) => {
+ const t = setTimeout(() => reject(new Error('rtc timeout')), 8000);
+ const on = () => {
+ if (pc.connectionState === 'connected') { clearTimeout(t); resolve(); }
+ else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { clearTimeout(t); reject(new Error('rtc ' + pc.connectionState)); }
+ };
+ pc.addEventListener('connectionstatechange', on);
+ on();
+ });
+ if (w.pc !== pc) return;
+ w.rtcFails = 0;
+ const again = (giveUp) => {
+ if (w.pc !== pc || w.video !== el) return;
+ if (giveUp || ++w.rtcFails > 3) w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint();
+ };
+ pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'failed') again(); });
+ // counted as watching (no playlist is asked for), and told of a new publish
+ w.rtcBeat = setInterval(() => {
+ fetch(base + 'stats?watching=1', { cache: 'no-store' }).then((x) => x.json())
+ .then((st) => { if (st.state === 'live' && st.rtc !== j.gen) again(st.rtc == null); }).catch(() => {});
+ }, 10_000);
+ }
+ function stopLiveWatch() {
+ const w = liveWatch;
+ if (!w) return;
+ liveWatch = null;
+ clearTimeout(w.actionTimer);
+ if (w.actionStop) w.actionStop();
+ if (w.sheet) ui.emojiPick = null;
+ for (const stop of w.stops) { try { stop(); } catch {} }
+ clearInterval(w.hereTimer);
+ if (w.said) presence(w, false); // gone
+ stopLivePlayer(w);
+ }
+ // What to play: the stream while it's on, its recording once it's over.
+ // A recording is played here only when it's video (HLS, MP4, WebM); some
+ // services name a web page instead (zap.stream streams kept on Rumble), and
+ // that gets a link out — a player can't play a page.
+ const videoFile = (u) => /\.(m3u8|mp4|m4v|webm|mov)(\?|#|$)/i.test(u || '');
+ const liveRecordingPage = (ev) => { const u = liveRecording(ev); return u && !videoFile(u) ? u : ''; };
+ const livePlayable = (ev) => (serverGone(ev) ? '' : isLiveNow(ev) ? liveStream(ev) : liveStatus(ev) === 'ended' && videoFile(liveRecording(ev)) ? liveRecording(ev) : '');
+ // The <video> is the page's own once it plays: Chrome takes the playlist as
+ // its src; anywhere without HLS of its own, hls.js feeds it. The morph
+ // leaves it alone from then on (it would strip the src hls.js set).
+ function mountLivePlayer() {
+ const w = liveWatch;
+ const el = document.querySelector('video.live-video:not(.bc-preview)');
+ if (!w || !el || el === w.video) return;
+ stopLivePlayer(w);
+ const ev = liveSeen.get(w.a);
+ const src = ev && livePlayable(ev);
+ if (!src) return;
+ w.video = el;
+ el._skipMorph = true;
+ w.err = null;
+ // a stream that drops (the streamer's link, a restart) is tried again
+ const fail = () => {
+ if (w.video !== el) return;
+ stopLivePlayer(w);
+ w.err = 'offline';
+ scheduleRepaint();
+ setTimeout(() => { if (liveWatch === w && w.err === 'offline') { w.err = null; scheduleRepaint(); } }, 10_000);
+ };
+ el.addEventListener('error', fail);
+ el.addEventListener('hlsfatal', fail);
+ el.addEventListener('playing', () => { if (w.err) { w.err = null; scheduleRepaint(); } });
+ const go = () => el.play().catch(() => { el.muted = true; el.play().catch(() => {}); });
+ // A stream that's on plays through hls.js wherever it runs: it keeps the
+ // viewer near the live edge (starts 1.5 s back, speeds up when it falls
+ // behind). Chrome's own HLS player starts three segments back, never
+ // catches up, and fails outright if a page changes its speed. Its player
+ // still takes recordings, and live streams where hls.js can't run (iOS).
+ // Measured (go-live-test LATENCY=1, one machine): 2 s segments on
+ // Chrome's player 8.6 s behind the broadcaster; 1 s segments 4.5 s.
+ // Ours, on: WebRTC first (a fraction of a second behind), HLS if it
+ // can't be had — no WebRTC for the stream, or no connection in 8 s
+ if (src === liveStream(ev) && OUR_LIVE.test(src) && typeof RTCPeerConnection !== 'undefined' && !w.noRtc) {
+ rtcWatch(w, el, src, go).catch(() => {
+ if (w.video !== el) return;
+ w.noRtc = true;
+ stopLivePlayer(w);
+ scheduleRepaint(); // mounted again, as HLS
+ });
+ return;
+ }
+ const live = src === liveStream(ev) && isHls(src) && typeof MediaSource !== 'undefined';
+ if (!isHls(src) || (canHls() && !live)) { el.src = src; go(); return; }
+ import('../hls-player.js').then((m) => m.attachHls(el, src, { live })).then((hls) => {
+ if (w.video !== el) { try { hls && hls.destroy(); } catch {} return; }
+ if (!hls) { w.err = 'noplayer'; scheduleRepaint(); return; }
+ w.hls = hls; go();
+ }).catch(() => { w.err = 'noplayer'; scheduleRepaint(); });
+ }
+ // after every render: a stream screen gone takes its player and watch with
+ // it (a <video> taken out of the page plays on, unheard and unseen)
+ let liveShown = false;
+ function liveAfterRender() {
+ // the camera shown before going live: let go of it once that screen is left
+ if (!ui.goLive && bc && bc.phase === 'setup') { bcStopMedia(); bc = null; }
+ const pv = document.querySelector('video.bc-preview');
+ if (pv && bc && bc.media && pv.srcObject !== bc.media) { pv._skipMorph = true; pv.srcObject = bc.media; pv.play().catch(() => {}); }
+ if (!ui.liveStream) { stopLiveWatch(); return; }
+ if (!liveShown) { stopLivePlayer(); return; }
+ mountLivePlayer();
+ const box = document.querySelector('.live-chat');
+ if (box && box._stick !== false) box.scrollTop = box.scrollHeight;
+ }
+
+ async function sendLiveChat(w, text, reply) {
+ const id = await requireIdentity();
+ const ev = liveSeen.get(w.a);
+ const relays = [...new Set([...liveRelays(ev), ...wallet.nostrRelays()])];
+ const partial = { kind: LIVE_CHAT_KIND, content: text, created_at: Math.floor(Date.now() / 1000),
+ tags: [['a', w.a, relays[0] || '', 'root'], ...(reply ? [['e', reply.id, relays[0] || '', 'reply'], ['p', reply.pubkey]] : []), ...outboundEmojiTags(text), CLIENT_TAG] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ w.lines.set(evt.id, evt);
+ setZapAddr(evt.id, w.a); watchLiveActions(w);
+ render();
+ if (!(await publishOn(relays, evt))) {
+ w.lines.delete(evt.id);
+ throw new Error(t('msgSendFailed'));
+ }
+ }
+
+ function liveMessageSheet(w) {
+ const ev = w.lines.get(w.sheet);
+ if (!ev || ev.kind !== LIVE_CHAT_KIND) return null;
+ const mine = liveReactions([...w.actions.values()]).get(ev.id)?.get(identityPk());
+ const close = () => { w.sheet = null; ui.emojiPick = null; render(); };
+ const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, h('span', { class: 'msg-sheet-ico' }, icon), label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: e => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col msg-sheet' },
+ reactRow(mine?.content, emoji => { close(); sendLiveReaction(w, ev, emoji); }),
+ ui.emojiPick ? null : item('↩', t('msgReply'), () => { w.reply = ev.id; close(); setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); }),
+ !ui.emojiPick && canZapPk(ev.pubkey) && !isMe(ev.pubkey) ? item('⚡', t('msgZap'), () => { close(); zapMessage(ev.pubkey, ev.id); }) : null,
+ ui.emojiPick ? null : item('⧉', t('copy'), async () => { try { await navigator.clipboard.writeText(ev.content); toast(t('copied')); } catch {} close(); })));
+ }
+ function liveLine(ev, w, reactions) {
+ if (ev.kind === LIVE_CHAT_KIND) {
+ if (hiddenPk(ev.pubkey)) return null;
+ const em = noteEmojiTags(ev), mine = isMe(ev.pubkey);
+ const counts = new Map();
+ for (const r of reactions.get(ev.id)?.values() || []) {
+ if (hiddenPk(r.pubkey)) continue;
+ const emoji = !r.content || r.content === '+' ? '❤️' : r.content;
+ const url = emojiTagMap(r.tags).get(shortcodeOf(emoji)) || null;
+ const key = JSON.stringify([emoji, url]);
+ const c = counts.get(key) || { emoji, url, n: 0, mine: false };
+ c.n++; c.mine ||= isMe(r.pubkey); counts.set(key, c);
+ }
+ const canZap = canZapPk(ev.pubkey) && !mine;
+ const zc = zapChip(ev.id, { cls: 'chat-react', onClick: canZap ? () => zapMessage(ev.pubkey, ev.id) : null, onHold: () => openZapSettings(ev.pubkey, ev.id) });
+ const reply = w.lines.get(ev.tags.find(t => t[0] === 'e')?.[1]);
+ return keyed(h('div', { class: 'chat-row live-message' + (mine ? ' mine' : '') },
+ avatar(ev.pubkey),
+ h('div', { class: 'chat-body' },
+ h('div', { class: 'chat-meta' },
+ h('span', { class: 'chat-name clickable', onClick: () => openProfile(ev.pubkey) }, displayName(ev.pubkey)),
+ h('span', { class: 'chat-time' }, timeLabel(ev.created_at * 1000))),
+ h('div', { class: 'chat-line' },
+ h('div', { class: 'chat-bubble clickable' + (emojiJumbo(ev.content, em) ? ' jumbo' : ''),
+ onClick: chatBubbleClick(() => { if (!isVisitor()) { w.sheet = ev.id; ui.emojiPick = null; syncEmoji().catch(() => {}); render(); } }),
+ },
+ reply && !hiddenPk(reply.pubkey) ? h('div', { class: 'chat-quote' },
+ h('span', { class: 'chat-quote-name' }, displayName(reply.pubkey)),
+ h('span', { class: 'chat-quote-text' }, reply.content.slice(0, 160))) : null,
+ ...noteBody(ev.content, 0, em),
+ zc || counts.size ? h('div', { class: 'chat-reacts' }, zc,
+ [...counts.values()].map(c => h('button', { type: 'button', class: 'chat-react' + (c.mine ? ' on' : ''), title: t('msgReact'), disabled: isVisitor(),
+ onClick: e => { e.stopPropagation(); sendLiveReaction(w, ev, c.emoji); },
+ }, postReactNode(c), c.n > 1 ? ' ' + c.n : ''))) : null),
+ msgZapBtn(ev.pubkey, ev.id)))), ev.id);
+ }
+ const z = zapLine(ev, decodeBolt11);
+ if (!z.sats || (z.pk && hiddenPk(z.pk))) return null;
+ return keyed(h('div', { class: 'live-line live-zap' },
+ z.pk ? avatar(z.pk, 'chat-avatar mini') : h('span', {}, '⚡'),
+ h('div', { class: 'live-line-body' },
+ h('span', { class: 'quote-name' }, z.pk ? displayName(z.pk) : t('liveSomeone')), ' ',
+ h('b', {}, t('liveZapped', { n: z.sats.toLocaleString() })),
+ z.text ? h('span', {}, ' ' + z.text.slice(0, 280)) : null)), ev.id);
+ }
+
+ function liveScreen() {
+ const s = ui.liveStream;
+ const w = watchLive(s);
+ liveShown = true;
+ // your own stream, going out: the camera itself, not the stream seconds behind it
+ const mine = !!bc && bc.phase === 'live' && bc.a === s.a;
+ if (mine) setTimeout(scheduleRepaint, 1000); // the clock
+ const ev = liveSeen.get(s.a) || s.seed;
+ const host = liveHost(ev), status = liveStatus(ev), n = liveViewers(ev);
+ const src = livePlayable(ev);
+ const reactions = liveReactions([...w.actions.values()]);
+ const lines = [...w.lines.values()].sort((a, b) => a.created_at - b.created_at).slice(-200).map(e => liveLine(e, w, reactions)).filter(Boolean);
+ const draftKey = 'live:' + w.a;
+ const reply = w.lines.get(w.reply);
+ const say = liveServerState(ev) === 'removed' ? t('liveRemoved') : liveServerState(ev) === 'deleted' ? t('liveDeletedSay') : w.err === 'offline' ? t('liveOffline') : w.err === 'noplayer' ? t('liveNoPlayer')
+ : !src ? (status === 'planned' ? t('liveStartsAt', { when: timeLabel(liveStarts(ev) * 1000) }) : t('liveOver')) : null;
+ const send = () => {
+ const text = draftFor(draftKey).trim();
+ if (!text || w.sending) return;
+ w.sending = true;
+ setDraft(draftKey, '');
+ w.reply = null;
+ const inp = document.getElementById('msg-draft');
+ if (inp) inp.value = '';
+ sendLiveChat(w, text, reply).catch(e => {
+ setDraft(draftKey, text + (draftFor(draftKey) ? '\n' + draftFor(draftKey) : ''));
+ if (!w.reply) w.reply = reply?.id || null;
+ if (!(e instanceof NoIdentity)) toast(t('msgSendFailed'));
+ }).finally(() => { w.sending = false; render(); });
+ };
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(!ui.pubProf && wallet.loaded),
+ h('div', { class: 'card col live-main', style: 'gap:10px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
+ : backBtn(() => { ui.liveStream = null; }),
+ h('h3', { class: 'live-title' }, liveTitle(ev) || t('liveUntitled')),
+ // yours, said to be on, but not going out from here: End it
+ !mine && ev.pubkey === mePk() && announcedStatus(ev) === 'live'
+ ? h('button', { class: 'btn-sm live-end-stale', onClick: () => endAnnounced(ev) }, t('goLiveEnd')) : null,
+ liveBadge(ev)),
+ mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
+ h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
+ watchingLine(w, bc.viewers || 0),
+ bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
+ : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
+ // camera ⇄ screen, without stopping the stream
+ navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } },
+ t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null,
+ h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
+ h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
+ h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
+ : h('div', { class: 'live-player', 'data-key': 'live-player:' + s.a },
+ // the frame is keyed by what plays in it: the <video> itself is
+ // never morphed, so a stream that ends or drops swaps the frame
+ src && !w.err
+ ? h('div', { class: 'live-frame', 'data-key': 'live-frame:' + src },
+ h('video', { class: 'live-video', controls: '', playsinline: '', autoplay: '', poster: liveImage(ev) || undefined }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'live-still', style: liveCoverStyle(ev) },
+ h('span', { class: 'live-say' }, say),
+ !src && liveRecordingPage(ev) ? h('a', { class: 'btn-sm live-rec-link', href: liveRecordingPage(ev), target: '_blank', rel: 'noopener noreferrer' }, t('liveWatchRecording')) : null)),
+ h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
+ avatar(host, 'chat-avatar mini'),
+ h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
+ n != null && status === 'live' ? watchingLine(w, n) : null,
+ h('span', { style: 'margin-left:auto' }),
+ canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
+ // ours to answer for: anyone signed in can report it, an admin can take it down
+ ourStreamId(ev) && !mine && !isVisitor() && host !== mePk() && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-report-btn', title: t('liveReport'), 'aria-label': t('liveReport'), onClick: () => { ui.liveReport = ui.liveReport ? null : { a: s.a, reason: null, text: '' }; render(); } }, '⚑') : null,
+ ourStreamId(ev) && mePk() === LIVE_ADMIN && host !== mePk() && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-takedown', style: 'color:var(--red)', onClick: () => takeDown(ev) }, t('liveTakeDown')) : null,
+ // your own, over: its recording is yours to delete
+ ourStreamId(ev) && !mine && ev.pubkey === mePk() && status === 'ended' && !serverGone(ev)
+ ? h('button', { class: 'btn-sm live-delete', style: 'color:var(--red)', title: t('liveDelete'), 'aria-label': t('liveDelete'), onClick: () => deleteRecording(ev) },
+ h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>' })) : null,
+ h('button', { class: 'btn-sm', title: t('postShare'), 'aria-label': t('postShare'), onClick: async () => {
+ const url = location.origin + '/' + packNaddr(s.a);
+ try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
+ } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))),
+ watchersRow(w),
+ liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
+ ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
+ h('div', { class: 'card col live-chat-card', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('liveChat')),
+ h('div', {
+ class: 'live-chat', 'data-key': 'live-chat:' + s.a,
+ onScroll: (e) => { const b = e.currentTarget; b._stick = b.scrollHeight - b.scrollTop - b.clientHeight < 40; },
+ }, ...(lines.length ? lines : [keyed(h('div', { class: 'small faint' }, t('liveChatEmpty')), 'empty')])),
+ reply ? h('div', { class: 'reply-bar' },
+ h('div', { class: 'col grow', style: 'min-width:0' },
+ h('span', { class: 'small' }, '↩ ', displayName(reply.pubkey)),
+ h('span', { class: 'small muted chat-quote-text' }, reply.content.slice(0, 160))),
+ h('button', { class: 'btn-ghost', onClick: () => { w.reply = null; render(); } }, '×')) : null,
+ isVisitor() ? null : composer(t('liveChatHint'), send, null, draftKey)),
+ w.sheet ? liveMessageSheet(w) : null,
+ ...noteOverlays());
+ }
+
+ // ---- going live -----------------------------------------------------------
+ // The camera (or the screen) recorded in half-second chunks and sent over a
+ // WebSocket to coinos's live server (coinosv3-server live/), which makes HLS
+ // of it; the stream is announced as your kind 30311, refreshed every minute
+ // with how many watch, and ended with its recording. A coinos name's owner
+ // only: the server asks the registrar. One at a time.
+ const liveIngest = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return (local ? location.origin : APP_BASE).replace(/^http/, 'ws') + '/live/ingest';
+ };
+ const recType = () => {
+ if (typeof MediaRecorder === 'undefined') return null;
+ return ['video/webm;codecs=h264,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find((m) => { try { return MediaRecorder.isTypeSupported(m); } catch { return false; } }) || null;
+ };
+ const canGoLive = () => !isVisitor() && typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && !!recType();
+ const clockOf = (sec) => { sec = Math.max(0, Math.floor(sec)); const hh = Math.floor(sec / 3600), mm = Math.floor(sec / 60) % 60, ss = sec % 60; return (hh ? hh + ':' + String(mm).padStart(2, '0') : String(mm)) + ':' + String(ss).padStart(2, '0'); };
+ let bc = null; // the broadcast: { phase, title, source, facing, media, rec, ws, id, a, ev, err, ... }
+ function openGoLive() {
+ if (bc && bc.a && bc.phase === 'live') { openLive(bc.ev); return; }
+ bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null };
+ ui.goLive = true;
+ ui.liveStream = null;
+ render();
+ bcPreview().catch(() => {});
+ }
+ function bcStopMedia() {
+ if (bc && bc.media) for (const tr of bc.media.getTracks()) { try { tr.stop(); } catch {} }
+ if (bc) bc.media = null;
+ }
+ // the picture to send: the camera (with its microphone), or the screen
+ // (with the microphone beside it, and the tab's sound when shared)
+ async function bcPreview() {
+ const b = bc;
+ if (!b) return;
+ // live, the old picture keeps going out until the new one is there (a
+ // cancelled screen picker mustn't leave the stream with nothing)
+ const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen';
+ if (!live) bcStopMedia();
+ b.err = null;
+ try {
+ let media;
+ if (b.source === 'screen') {
+ media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
+ if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
+ // the browser's own "Stop sharing", live: back to the camera
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } });
+ } else {
+ media = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
+ audio: { echoCancellation: true, noiseSuppression: true },
+ });
+ }
+ if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ if (live) bcStopMedia();
+ b.media = media;
+ // a switch mid-stream: the recorder carries on with the new picture,
+ // and the WebRTC copy swaps it in where it is
+ if (b.phase === 'live') { bcRecord(b, true); bcRtcSwap(b); }
+ } catch (e) {
+ if (bc !== b) return;
+ if (live) b.source = was; // no screen after all: the camera goes on
+ else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ }
+ render();
+ }
+ async function liveAuth(b) {
+ const id = await requireIdentity();
+ const partial = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['u', APP_BASE + '/live/ingest'], ['method', 'GET']] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ b.pk = id.pubkey;
+ return btoa(JSON.stringify(evt));
+ }
+ // one connection to the server: its first word is where the stream is
+ function bcConnect(b, resume) {
+ return liveAuth(b).then((auth) => new Promise((resolve, reject) => {
+ const name = (hook('namesAddress') || '').split('@')[0];
+ const ws = new WebSocket(liveIngest() + '?auth=' + encodeURIComponent(auth) + '&name=' + encodeURIComponent(name) + (resume ? '&resume=' + resume : ''));
+ ws.binaryType = 'arraybuffer';
+ let hello = false;
+ ws.onmessage = (e) => {
+ if (hello || typeof e.data !== 'string') return;
+ hello = true;
+ try { resolve({ ws, info: JSON.parse(e.data) }); } catch (err) { reject(err); }
+ };
+ ws.onerror = () => { if (!hello) reject(new Error(t('goLiveRefused'))); };
+ ws.onclose = () => {
+ if (!hello) { reject(new Error(t('goLiveRefused'))); return; }
+ if (bc === b && b.ws === ws && b.phase === 'live') bcReconnect(b);
+ };
+ }));
+ }
+ // a recorder over the current picture, its chunks to the server; a new one
+ // (a new connection, a new camera) starts a fresh recording, header and all
+ function bcRecord(b, again = false) {
+ if (b.rec) { try { b.rec.ondataavailable = null; b.rec.stop(); } catch {} }
+ if (again && b.ws) { try { b.ws.close(1000, 'switch'); } catch {} return; } // the reconnect brings the new recording
+ const rec = new MediaRecorder(b.media, { mimeType: recType(), videoBitsPerSecond: 2_500_000, audioBitsPerSecond: 128_000 });
+ rec.ondataavailable = (e) => {
+ if (!e.data || !e.data.size || bc !== b || !b.ws || b.ws.readyState !== 1) return;
+ // more than a few seconds unsent: the link can't keep up
+ b.behind = b.ws.bufferedAmount > 4_000_000;
+ e.data.arrayBuffer().then((buf) => { if (b.ws && b.ws.readyState === 1) b.ws.send(buf); });
+ };
+ rec.start(500);
+ b.rec = rec;
+ }
+ async function bcReconnect(b) {
+ if (b.reconnecting) return;
+ b.reconnecting = true;
+ b.ws = null;
+ render();
+ for (let i = 0; i < 10 && bc === b && b.phase === 'live'; i++) {
+ try {
+ const { ws, info } = await bcConnect(b, b.id);
+ if (bc !== b || b.phase !== 'live') { ws.close(); break; }
+ b.ws = ws;
+ bcRecord(b);
+ b.reconnecting = false;
+ // The server couldn't take us back (it restarted, or we were gone
+ // too long): this is a new stream at a new address. The old one is
+ // over — said so, with no recording (there's next to nothing in
+ // it) — and the new one is announced as soon as it plays.
+ if (info.id !== b.id) {
+ toast(t('goLiveRestarted'));
+ const old = { ...b, recording: null };
+ Object.assign(b, { id: info.id, liveUrl: info.live, recording: info.recording, a: LIVE_KIND + ':' + b.pk + ':' + info.id });
+ bcRtc(b).catch(() => {});
+ bcAnnounce(old, 'ended').catch(() => {});
+ bcPlaying(b).then(() => (bc === b && b.phase === 'live' ? bcAnnounce(b, 'live') : null)).then(() => {
+ // the streamer's own view of it follows to the new address
+ if (bc === b && b.ev && ui.liveStream && ui.liveStream.a === old.a) { ui.liveStream = { a: b.a, seed: b.ev }; render(); }
+ }).catch(() => {});
+ }
+ render();
+ return;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+ b.reconnecting = false;
+ if (bc === b && b.phase === 'live') { toast(t('goLiveLost')); endBroadcast(); }
+ }
+ // The stream as WebRTC, for the app's own viewers: the picture also goes to
+ // Cloudflare's SFU (the live server brokers it), and a viewer pulls it from
+ // there a fraction of a second behind — HLS is seconds behind. Best effort:
+ // the HLS above is the stream either way, and the recording.
+ async function bcRtc(b, tries = 0) {
+ if (typeof RTCPeerConnection === 'undefined' || bc !== b || b.phase !== 'live' || !b.id || !b.media) return;
+ bcRtcStop(b);
+ const id = b.id;
+ const pc = b.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }], bundlePolicy: 'max-bundle' });
+ const again = () => {
+ if (bc !== b || b.pc !== pc || b.phase !== 'live' || tries >= 5) return;
+ bcRtcStop(b);
+ setTimeout(() => bcRtc(b, tries + 1).catch(() => {}), 3000 * (tries + 1));
+ };
+ pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') again(); else if (pc.connectionState === 'connected') tries = 0; };
+ try {
+ const vt = b.media.getVideoTracks()[0], at = b.media.getAudioTracks()[0];
+ const tv = pc.addTransceiver(vt || 'video', { direction: 'sendonly', sendEncodings: [{ maxBitrate: 2_500_000 }] });
+ const ta = pc.addTransceiver(at || 'audio', { direction: 'sendonly' });
+ await pc.setLocalDescription(await pc.createOffer());
+ const u = liveBase() + '/live/' + id + '/rtc/publish';
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/' + id + '/rtc/publish'], ['method', 'POST']] });
+ const r = await fetch(u, { method: 'POST', headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)), 'content-type': 'application/json' },
+ body: JSON.stringify({ sdp: pc.localDescription.sdp, tracks: [{ mid: tv.mid, trackName: 'video' }, { mid: ta.mid, trackName: 'audio' }] }) });
+ if (r.status === 501 || r.status === 403) { bcRtcStop(b); return; } // no WebRTC on this server
+ if (!r.ok) throw new Error('publish ' + r.status);
+ const j = await r.json();
+ if (b.pc !== pc) return;
+ await pc.setRemoteDescription({ type: 'answer', sdp: j.sdp });
+ } catch { again(); }
+ }
+ function bcRtcSwap(b) {
+ if (!b.pc || !b.media) return;
+ for (const tr of b.pc.getTransceivers()) {
+ const kind = tr.receiver && tr.receiver.track && tr.receiver.track.kind;
+ const next = kind === 'video' ? b.media.getVideoTracks()[0] : kind === 'audio' ? b.media.getAudioTracks()[0] : null;
+ if (kind) tr.sender.replaceTrack(next || null).catch(() => {});
+ }
+ }
+ function bcRtcStop(b) {
+ if (b && b.pc) { try { b.pc.close(); } catch {} b.pc = null; }
+ }
+ // the announcement: your 30311, and its copies as the stream goes on
+ async function bcAnnounce(b, status) {
+ const id = await requireIdentity();
+ const relays = [...new Set([...LIVE_RELAYS, ...wallet.nostrRelays()])];
+ const me = profileOf(id.pubkey) || {};
+ const tags = [['d', b.id], ['title', b.title || t('goLiveDefaultTitle', { name: displayName(id.pubkey) })],
+ ['status', status], ['streaming', b.liveUrl], ['starts', String(b.starts)],
+ ['p', id.pubkey, '', 'Host'], ['relays', ...relays.slice(0, 6)], CLIENT_TAG];
+ if (me.picture) tags.push(['image', me.picture]);
+ if (status === 'live' && b.viewers != null) tags.push(['current_participants', String(b.viewers)]);
+ if (status === 'ended') tags.push(['ends', String(Math.floor(Date.now() / 1000))]);
+ if (status === 'ended' && b.recording) tags.push(['recording', b.recording]);
+ // each copy newer than the last, to the second: an `ended` stamped the
+ // same second as the `live` before it lost to it (relays keep the lower
+ // id on a tie) and the stream read live for good
+ const prev = liveSeen.get(LIVE_KIND + ':' + id.pubkey + ':' + b.id);
+ const partial = { kind: LIVE_KIND, content: '', created_at: Math.max(Math.floor(Date.now() / 1000), prev ? prev.created_at + 1 : 0), tags };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ takeLive([evt]);
+ if (bc === b) b.ev = evt;
+ scheduleRepaint();
+ return publishOn(relays, evt);
+ }
+ async function bcPlaying(b) {
+ const url = b.liveUrl;
+ for (let i = 0; i < 40 && bc === b && b.phase === 'live' && b.liveUrl === url; i++) {
+ await new Promise((r) => setTimeout(r, 500));
+ const pl = await fetch(url, { cache: 'no-store' }).then((r) => r.text()).catch(() => '');
+ if ((pl.match(/\.ts\s*$/gm) || []).length >= 3) break;
+ }
+ }
+ async function startBroadcast() {
+ const b = bc;
+ if (!b || b.phase !== 'setup' || !b.media) return;
+ b.phase = 'starting'; b.err = null;
+ render();
+ try {
+ const { ws, info } = await bcConnect(b, null);
+ if (bc !== b) { ws.close(); return; }
+ Object.assign(b, { ws, id: info.id, liveUrl: info.live, recording: info.recording, starts: Math.floor(Date.now() / 1000), viewers: 0 });
+ b.phase = 'live';
+ b.a = LIVE_KIND + ':' + b.pk + ':' + b.id;
+ bcRecord(b);
+ bcRtc(b).catch(() => {});
+ try { b.lock = await navigator.wakeLock?.request('screen'); } catch {}
+ // the stream is a few seconds behind the camera: announced once it has
+ // three segments — Chrome's own player won't start a live playlist with
+ // fewer (it fails to parse it), so a viewer who comes at once would get
+ // an error, not the stream
+ await bcPlaying(b);
+ if (bc !== b || b.phase !== 'live') return;
+ await bcAnnounce(b, 'live');
+ b.tick = setInterval(() => bcTick(b), 60_000);
+ ui.goLive = null;
+ openLive(b.ev);
+ } catch (e) {
+ if (bc !== b) return;
+ b.phase = 'setup';
+ b.err = (e && e.message) || t('goLiveRefused');
+ try { b.ws && b.ws.close(); } catch {}
+ render();
+ }
+ }
+ // every minute: how many watch, into a fresh copy of the announcement
+ async function bcTick(b) {
+ if (bc !== b || b.phase !== 'live') return;
+ try {
+ const st = await (await fetch(b.liveUrl.replace(/live\.m3u8$/, 'stats'))).json();
+ b.viewers = st.viewers;
+ } catch {}
+ bcAnnounce(b, 'live').catch(() => {});
+ }
+ async function endBroadcast() {
+ const b = bc;
+ if (!b || b.phase === 'ended') return;
+ const was = b.phase;
+ b.phase = 'ended';
+ clearInterval(b.tick);
+ try { b.rec && b.rec.stop(); } catch {}
+ bcRtcStop(b);
+ try { if (b.ws && b.ws.readyState === 1) b.ws.send('end'); } catch {}
+ setTimeout(() => { try { b.ws && b.ws.close(1000, 'ended'); } catch {} }, 500);
+ bcStopMedia();
+ try { b.lock && b.lock.release(); } catch {}
+ bc = null;
+ ui.goLive = null;
+ render();
+ if (was === 'live' && b.id) {
+ await bcAnnounce(b, 'ended').catch(() => {});
+ toast(t('goLiveEnded'));
+ }
+ }
+ // the screen before going live: what to send, a title, the picture itself
+ function goLiveScreen() {
+ if (!bc) { bc = { phase: 'setup', title: '', source: 'camera', facing: 'user', media: null, err: null }; queueMicrotask(() => bcPreview().catch(() => {})); } // (a reload, back on this screen)
+ const b = bc;
+ const name = hook('namesAddress');
+ const screenOk = !!navigator.mediaDevices?.getDisplayMedia;
+ queueMicrotask(() => {
+ const v = document.querySelector('video.bc-preview');
+ if (v && b.media && v.srcObject !== b.media) { v._skipMorph = true; v.srcObject = b.media; v.play().catch(() => {}); }
+ });
+ const pick = (src) => { if (b.source === src) return; b.source = src; render(); bcPreview(); };
+ // the server's say first (a name a day old, not barred), so Start doesn't just fail
+ if (name && b.allowed === undefined) {
+ b.allowed = null;
+ signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/allowed'], ['method', 'GET']] })
+ .then((auth) => fetch(liveBase() + '/live/allowed?name=' + encodeURIComponent(name.split('@')[0]), { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) }))
+ .then((r) => r.json()).then((j) => { b.allowed = j; render(); })
+ .catch(() => { b.allowed = { ok: true }; render(); }); // unasked: the server still decides at Start
+ }
+ const refused = b.allowed && b.allowed.ok === false ? b.allowed : null;
+ return h('div', { class: 'col live-page', style: 'gap:12px' },
+ ctx.brandHeader(true),
+ h('div', { class: 'card col', style: 'gap:12px' },
+ h('div', { class: 'row gap6', style: 'align-items:center' },
+ backBtn(() => { bcStopMedia(); bc = null; ui.goLive = null; }),
+ h('h3', { style: 'margin:0' }, t('goLive'))),
+ !name ? h('div', { class: 'notice small' }, t('goLiveNeedsName')) : null,
+ refused ? h('div', { class: 'notice small go-live-refused' }, refused.reason === 'new-name' && refused.until
+ ? t('goLiveNewName', { when: timeLabel(refused.until) }) : refused.reason === 'banned' ? t('goLiveBanned') : t('goLiveNeedsName')) : null,
+ h('div', { class: 'live-player', 'data-key': 'bc-player:' + b.source },
+ b.media
+ ? h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + b.media.id }, h('video', { class: 'live-video bc-preview' + (b.source === 'camera' && b.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' }))
+ : h('div', { class: 'live-cover live-still', 'data-key': 'bc-still' }, h('span', { class: 'live-say' }, b.err || t('goLiveAsking')))),
+ h('div', { class: 'row gap6 wrap' },
+ h('button', { class: 'btn-sm' + (b.source === 'camera' ? ' on' : ''), onClick: () => pick('camera') }, t('goLiveCamera')),
+ screenOk ? h('button', { class: 'btn-sm' + (b.source === 'screen' ? ' on' : ''), onClick: () => pick('screen') }, t('goLiveScreen')) : null,
+ b.source === 'camera' ? h('button', { class: 'btn-sm', title: t('goLiveFlip'), onClick: () => { b.facing = b.facing === 'user' ? 'environment' : 'user'; bcPreview(); } }, '⇄ ' + t('goLiveFlip')) : null),
+ h('input', { type: 'text', class: 'go-live-title', placeholder: t('goLiveTitlePh'), value: b.title, maxlength: '120',
+ onInput: (e) => { b.title = e.target.value; } }),
+ b.err && b.media ? h('div', { class: 'notice err small' }, b.err) : null,
+ h('button', { class: 'btn-primary btn-block go-live-start', disabled: !name || !b.media || b.phase !== 'setup' || !!refused, onClick: startBroadcast },
+ b.phase === 'starting' ? h('span', { class: 'spinner sm' }) : null, ' ', t(b.phase === 'starting' ? 'goLiveStarting' : 'goLiveStart')),
+ h('div', { class: 'small faint' }, t('goLiveHint'))));
+ }
+
+ // ---- calls (NIP-AC: src/calls.js) ------------------------------------------
+ // A 📞 or 🎥 in a conversation's header calls that person; a call from
+ // someone you follow (or talk to) rings over whatever page is up. The setup
+ // goes as ephemeral gift wraps to their DM inboxes and ours; TURN comes from
+ // coinos's live server (Cloudflare's), for the connections that can't go direct.
+ const liveBase = () => {
+ const local = typeof location !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname);
+ return local ? location.origin : APP_BASE;
+ };
+ let calls = null;
+ const callInbox = new Map(); // peer -> their inbox relays, for this session
+ // Relays that turned a call message away are left out of the next ones: a
+ // relay that won't take the kind (nip17.com: "only kind 1059") for a day,
+ // one rate-limiting or banning us for ten minutes. Kept on this device.
+ const CALL_SHUN_KEY = 'coinos-call-shun';
+ let callShun = (() => { try { return JSON.parse(localStorage.getItem(CALL_SHUN_KEY) || '{}'); } catch { return {}; } })(); // relay -> until
+ function callRefused(url, msg) {
+ const kindRule = /only kind|not (accepted|allowed)|blocked|unsupported|restricted|kind \d+/i.test(msg) && !/rate|too many|banned|slow down/i.test(msg);
+ callShun[url] = Date.now() + (kindRule ? 86400_000 : 600_000);
+ try { localStorage.setItem(CALL_SHUN_KEY, JSON.stringify(callShun)); } catch {}
+ }
+ function callRelaysOf(relays) {
+ const now = Date.now();
+ const ok = relays.filter((r) => !(callShun[r] > now));
+ return ok.length ? ok : relays; // all of them shunned: try them anyway
+ }
+ async function inboxFor(pk) {
+ if (callInbox.has(pk)) return callInbox.get(pk);
+ const p = (isMe(pk) ? Promise.resolve(ownInbox) : fetchInboxRelays(pk).catch(() => []))
+ .then((l) => [...new Set([...DM_RELAYS, ...(l || []).filter((r) => /^wss:\/\//i.test(r)).slice(0, 4)])]);
+ callInbox.set(pk, p);
+ return p;
+ }
+ async function signAsMe(tmpl) {
+ const id = await requireIdentity();
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ }
+ let iceCache = null; // { servers, until }
+ async function callIce() {
+ if (iceCache && Date.now() < iceCache.until) return iceCache.servers;
+ const STUN = [{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302'] }];
+ try {
+ const auth = await signAsMe({ kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['u', APP_BASE + '/live/turn'], ['method', 'GET']] });
+ const r = await fetch(liveBase() + '/live/turn', { headers: { authorization: 'Nostr ' + btoa(JSON.stringify(auth)) }, signal: AbortSignal.timeout(8000) });
+ const j = await r.json();
+ if (!Array.isArray(j.iceServers) || !j.iceServers.length) return STUN;
+ iceCache = { servers: j.iceServers, until: Date.now() + Math.max(60, (j.ttl || 600) - 600) * 1000 };
+ return j.iceServers;
+ } catch { return STUN; }
+ }
+ function callMedia(type, facing = 'user') {
+ const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
+ const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
+ if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
+ return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
+ }
+ // who may ring: someone you follow, or someone you've written to
+ // who may ring: someone you follow, or someone you've written to. Anyone
+ // else's call doesn't ring but is told of, as a missed call that didn't
+ // ring (a wife's call must not vanish because you never followed her);
+ // a muted person's leaves nothing.
+ const mayRing = (pk) => hiddenPk(pk) ? null
+ : followsNow().set.has(pk) || wroteTo(pk) ? 'ring' : 'quiet';
+ // a message of ours in the conversation — not a call logged there (a
+ // stranger's unrung call would otherwise let their next one ring)
+ function wroteTo(pk) {
+ for (const m of (threads.get(pk) || new Map()).values()) if (m.mine && m.rumor.kind !== CALL_LOG) return true;
+ return false;
+ }
+ function callsNow() {
+ if (calls) return calls;
+ calls = createCalls({
+ me: () => dmSubKeys()[0],
+ sign: signAsMe,
+ // An offer, answer, hang-up or decline nobody took is tried again for a
+ // while (a relay's per-minute limit, a socket reconnecting): a lost
+ // answer left the caller ringing on and the callee "Connecting…".
+ // A late route isn't worth the wait.
+ send: async (pk, signed) => {
+ const tries = signed.kind === CALL.ICE ? 1 : 4;
+ for (let i = 0; i < tries; i++) {
+ if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1)));
+ const relays = callRelaysOf(await inboxFor(pk));
+ if (await publishOn(relays, wrapCall(signed, pk), { onauth: relayAuth, onrefused: callRefused })) return;
+ }
+ throw new Error('not delivered');
+ },
+ iceServers: callIce,
+ media: callMedia,
+ mayRing: async (pk) => mayRing(pk),
+ changed: () => { callRing(); render(); },
+ ended: (rec) => { logCall(rec).catch(() => {}); },
+ warm: (pk) => callWarm(pk),
+ // a key of our own signs each candidate at once; a remote signer gets
+ // them all inside the offer or answer instead
+ trickle: () => { try { if (localStorage.getItem('coinos-call-trickle') === '0') return false; } catch {} const id = hook('nostrLoginIdentity'); return !id || id.signer instanceof Uint8Array; },
+ });
+ return calls;
+ }
+ // a wrap addressed to us: opened by whichever of our keys it's for
+ async function onCallWrap(wrap) {
+ for (const d of dmDecryptors()) {
+ const dec = d instanceof Uint8Array ? async (pk, ct) => nip44.decrypt(ct, nip44.getConversationKey(d, pk)) : (pk, ct) => d.decryptFrom(pk, ct);
+ const inner = await openCall(wrap, dec).catch(() => null);
+ if (inner) { profileOf(inner.pubkey); await callsNow().handle(inner); return; }
+ }
+ }
+ // The call log: each call a row in its conversation ("Outgoing video call
+ // 4:12", "Missed voice call"), kept like a message and sent to our own
+ // inbox (kind 1405 in a gift wrap, to us alone — a kind the NIP-AC
+ // discussion uses for logs, which other clients pass over) so our other
+ // devices list it too. A call rung on two of our devices is logged by
+ // both; the conversation shows it once, by its call-id.
+ const callStatus = (rec) => rec.connected ? 'answered'
+ : rec.reason === 'missed' || rec.reason === 'unrung' ? 'missed'
+ : rec.reason === 'declined' || rec.reason === 'peer-declined' ? 'declined'
+ : rec.reason === 'no-answer' ? 'no-answer'
+ : rec.reason === 'busy' ? 'busy'
+ : rec.reason === 'hung-up' ? 'cancelled'
+ : 'failed';
+ const CALL_RANK = { answered: 5, declined: 4, busy: 3, 'no-answer': 2, cancelled: 2, failed: 1, missed: 0 };
+ function callLogText(dir, type, status, seconds) {
+ const video = type === 'video';
+ const key = status === 'answered' ? (dir === 'out' ? 'callLogOut' : 'callLogIn')
+ : status === 'missed' ? 'callLogMissed'
+ : status === 'declined' ? (dir === 'out' ? 'callLogTheyDeclined' : 'callLogDeclined')
+ : status === 'no-answer' ? 'callLogNoAnswer'
+ : status === 'cancelled' ? 'callLogCancelled'
+ : status === 'busy' ? 'callLogBusy' : 'callLogFailed';
+ const what = t(key, { what: t(video ? 'callLogVideo' : 'callLogVoice') });
+ return status === 'answered' && seconds ? what + ' · ' + clockOf(seconds) : what;
+ }
+ async function logCall(rec) {
+ if (!rec || !rec.peer || /elsewhere$/.test(rec.reason || '')) return; // the device that took it logs it
+ // (a missed call found as the app opens may come before its keys do)
+ let id = await identity();
+ for (let i = 0; !id && i < 15; i++) { await new Promise((r) => setTimeout(r, 1000)); id = await identity(); }
+ if (!id) return;
+ const status = callStatus(rec);
+ const rumor = rumorWithId({
+ kind: CALL_LOG, pubkey: id.pubkey, created_at: Math.floor((rec.at || Date.now()) / 1000),
+ content: callLogText(rec.dir, rec.type, status, rec.seconds),
+ tags: [['p', rec.peer], ['call-id', rec.id], ['call-type', rec.type], ['direction', rec.dir], ['status', status],
+ ['duration', String(rec.seconds || 0)], ...(rec.reason === 'unrung' ? [['rang', 'no']] : []), ['alt', 'Call log']],
+ });
+ noteDM(rec.peer, rumor, true);
+ persistDms();
+ // to our other devices — not through Amber, a prompt per call for a log line
+ if (!(id.signer instanceof Uint8Array) && (id.signer.interactive || !id.signer.encryptTo)) return;
+ const toSelf = await wrapDM(id.signer, id.pubkey, rumor);
+ publishOn(DM_RELAYS, toSelf);
+ toOwnInbox(toSelf);
+ }
+ // A conversation's rows with each call once: its most telling log (one
+ // device answered it while another only saw it ring)
+ function foldCallLogs(msgs) {
+ const best = new Map();
+ for (const m of msgs) {
+ if (m.rumor.kind !== CALL_LOG) continue;
+ const cid = (m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id;
+ const st2 = (x) => CALL_RANK[(x.rumor.tags.find((y) => y[0] === 'status') || [])[1]] ?? 0;
+ const cur = best.get(cid);
+ if (!cur || st2(m) > st2(cur)) best.set(cid, m);
+ }
+ return msgs.filter((m) => m.rumor.kind !== CALL_LOG || best.get((m.rumor.tags.find((x) => x[0] === 'call-id') || [])[1] || m.rumor.id) === m);
+ }
+ function callLogRow(m, peer) {
+ const tag = (k) => (m.rumor.tags.find((x) => x[0] === k) || [])[1];
+ const type = tag('call-type') === 'video' ? 'video' : 'voice', status = tag('status') || 'failed', dir = tag('direction') || 'out';
+ const missed = status === 'missed' || (dir === 'in' && status === 'declined');
+ // a call that didn't ring (someone you don't follow), saying so — and,
+ // while that's still so, how to have them ring next time
+ const unrung = tag('rang') === 'no';
+ return h('div', { class: 'chat-row call-log' + (missed ? ' missed' : ''), 'data-call-log': tag('call-id') || '' },
+ h('button', { type: 'button', class: 'call-log-pill', title: t(type === 'video' ? 'callVideo' : 'callVoice'), onClick: () => startCall(peer, type) },
+ callIcon(type === 'video' ? 'video' : 'phone'),
+ h('span', {}, callLogText(dir, type, status, +tag('duration') || 0)),
+ h('span', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))),
+ unrung ? h('div', { class: 'small faint call-log-why' }, t(mayRing(peer) === 'ring' ? 'callLogUnrungWas' : 'callLogUnrung')) : null);
+ }
+
+ // the call offer a push brought (sw-nwc.js keeps it): rung now, once
+ async function pendingCall() {
+ try {
+ if (typeof caches === 'undefined') return;
+ const cache = await caches.open('coinos-call');
+ // calls the worker saw from people who don't ring us: logged, unrung
+ const quiet = await cache.match('/__quiet-calls');
+ if (quiet) {
+ await cache.delete('/__quiet-calls');
+ for (const offer of (await quiet.json()) || []) {
+ if (!offer || !offer.pubkey || !mayRing(offer.pubkey)) continue;
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'unrung', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ }
+ const r = await cache.match('/__pending-call');
+ if (!r) return;
+ await cache.delete('/__pending-call');
+ const { offer, at } = await r.json();
+ if (!offer) return;
+ if (Date.now() - at < RING_S * 1000) { await callsNow().handle(offer, { maxAge: RING_S }); return; }
+ // rang while the app was shut, and nobody came: a missed call in the conversation
+ if (mayRing(offer.pubkey)) {
+ const type = (offer.tags.find((x) => x[0] === 'call-type') || [])[1] === 'video' ? 'video' : 'voice';
+ const id = (offer.tags.find((x) => x[0] === 'call-id') || [])[1];
+ if (id) logCall({ id, peer: offer.pubkey, type, dir: 'in', reason: 'missed', seconds: 0, at: offer.created_at * 1000 }).catch(() => {});
+ }
+ } catch {}
+ }
+ // A call with them may be coming (it rings, or their conversation is open):
+ // their relays looked up, TURN fetched — the seconds the first send or
+ // answer used to spend on it. Not through Amber: a prompt to open a chat.
+ const warmed = new Set();
+ function callWarm(pk) {
+ if (!pk || warmed.has(pk)) return;
+ warmed.add(pk);
+ inboxFor(pk).catch(() => {});
+ const login = hook('nostrLoginIdentity');
+ if (!(login && login.signer && login.signer.interactive)) callIce().catch(() => {});
+ }
+ function startCall(peer, type) {
+ if (typeof RTCPeerConnection === 'undefined' || !navigator.mediaDevices?.getUserMedia) { toast(t('callUnsupported')); return; }
+ const cur = callsNow().call;
+ if (cur && cur.state !== 'ended') { ui.callMin = false; render(); return; }
+ ui.callMin = false;
+ callsNow().start(peer, type).catch(() => {});
+ }
+
+ // The ring: a tone (two short bursts every few seconds) and the phone's
+ // buzz for a call coming in, a softer one while ours rings them; and, with
+ // the app out of sight, a notification.
+ let ringCtx = null, ringTimer = null, ringFor = null;
+ function tone(ctx, freqs, at, len, vol) {
+ for (const f of freqs) {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.frequency.value = f; o.connect(g); g.connect(ctx.destination);
+ g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(vol, at + 0.02); g.gain.setValueAtTime(vol, at + len - 0.05); g.gain.linearRampToValueAtTime(0, at + len);
+ o.start(at); o.stop(at + len + 0.05);
+ }
+ }
+ function callRing() {
+ const c = calls && calls.call;
+ const want = c && (c.state === 'incoming' || c.state === 'offering') ? c.id + ':' + c.state : null;
+ if (want === ringFor) return;
+ ringFor = want;
+ clearInterval(ringTimer); ringTimer = null;
+ try { navigator.vibrate && navigator.vibrate(0); } catch {}
+ // answered, declined or over: its "is calling" notification goes too
+ if (!want || want.endsWith(':offering')) {
+ try { navigator.serviceWorker?.ready.then((r) => r.getNotifications({ tag: 'call' })).then((ns) => { for (const n of ns || []) n.close(); }).catch(() => {}); } catch {}
+ }
+ if (!want) return;
+ const incoming = c.state === 'incoming';
+ const beat = () => {
+ try {
+ ringCtx = ringCtx || new (window.AudioContext || window.webkitAudioContext)();
+ if (ringCtx.state === 'suspended') ringCtx.resume().catch(() => {});
+ const t0 = ringCtx.currentTime + 0.05;
+ if (incoming) { tone(ringCtx, [440, 480], t0, 0.4, 0.12); tone(ringCtx, [440, 480], t0 + 0.6, 0.4, 0.12); }
+ else tone(ringCtx, [440, 480], t0, 1.2, 0.05); // the ringback, heard by the caller
+ } catch {}
+ if (incoming) { try { navigator.vibrate && navigator.vibrate([400, 200, 400]); } catch {} }
+ };
+ beat();
+ ringTimer = setInterval(beat, incoming ? 3000 : 4000);
+ if (incoming && typeof document !== 'undefined' && document.hidden) {
+ try {
+ if (Notification.permission === 'granted') navigator.serviceWorker?.ready.then((r) => r.showNotification(t('callIncomingFrom', { name: displayName(c.peer) }), {
+ body: t(c.type === 'video' ? 'callIncomingVideo' : 'callIncomingVoice'), tag: 'call', renotify: true, requireInteraction: true,
+ })).catch(() => {});
+ } catch {}
+ }
+ }
+
+ // the call's sound and pictures into their elements, after each paint
+ function mountCallMedia() {
+ const c = calls && calls.call;
+ const put = (sel, stream) => {
+ const el = document.querySelector(sel);
+ if (el && stream && el.srcObject !== stream) { el.srcObject = stream; el.play().catch(() => {}); }
+ };
+ if (!c) return;
+ put('audio.call-audio', c.remote);
+ put('video.call-remote', c.remote);
+ put('video.call-local', c.local);
+ }
+ const callReason = (r) => t({ 'no-answer': 'callNoAnswer', 'peer-declined': 'callDeclined', busy: 'callBusy', failed: 'callFailed', error: 'callFailed',
+ 'no-permission': 'callNoPermission', missed: 'callMissed', 'answered-elsewhere': 'callAnsweredElsewhere', 'declined-elsewhere': 'callDeclinedElsewhere' }[r] || 'callEnded');
+ const CALL_ICONS = {
+ phone: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L8 9.8a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2z"/></svg>',
+ video: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>',
+ mic: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
+ micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
+ camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
+ flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>',
+ hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
+ min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
+ };
+ const callIcon = (k) => h('span', { style: 'display:flex', html: CALL_ICONS[k] });
+ // The call, over any page: who, how it's going, and its buttons; minimized,
+ // a bar across the top to come back by.
+ function callOverlay() {
+ const c = calls && calls.call;
+ if (!c) return null;
+ queueMicrotask(mountCallMedia);
+ if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
+ const video = c.type === 'video';
+ // a picture that's coming (a muted track: they stopped their camera or share)
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted);
+ const camOn = !!c.local && c.local.getVideoTracks().length > 0;
+ const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
+ const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
+ : c.state === 'starting' || c.state === 'offering' ? t('callCalling')
+ : c.state === 'connecting' ? t('callConnecting')
+ : c.state === 'connected' ? clockOf((Date.now() - c.connectedAt) / 1000)
+ : callReason(c.reason);
+ if (ui.callMin && c.state !== 'incoming' && c.state !== 'ended') {
+ return h('div', { class: 'call-overlay call-min', 'data-key': 'call-overlay' }, sound,
+ h('button', { class: 'call-min-bar', onClick: () => { ui.callMin = false; render(); } },
+ h('span', { class: 'go-live-dot' }), h('span', { class: 'call-min-name' }, displayName(c.peer)), h('span', { class: 'call-min-status' }, status)));
+ }
+ const btn = (cls, icon, label, onClick, on = false) => h('button', { class: 'call-btn ' + cls + (on ? ' on' : ''), title: label, 'aria-label': label, onClick }, callIcon(icon));
+ return h('div', { class: 'call-overlay' + (remoteVideo && c.state === 'connected' ? ' has-video' : ''), 'data-key': 'call-overlay' },
+ sound,
+ video ? h('video', { class: 'call-remote', 'data-key': 'call-remote:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ h('div', { class: 'call-who' },
+ avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
+ h('div', { class: 'call-name' }, displayName(c.peer)),
+ h('div', { class: 'call-status' }, status)),
+ video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ c.state === 'incoming'
+ ? h('div', { class: 'call-bar' },
+ btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
+ btn('call-accept', video ? 'video' : 'phone', t('callAccept'), () => callsNow().accept()))
+ : c.state === 'ended' ? null
+ : h('div', { class: 'call-bar' },
+ btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
+ // a camera in the call: off/on and flip; none yet (a voice call, or
+ // theirs came on first): one tap puts ours in
+ camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ camOn && !c.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
+ // a browser that can share its screen (desktops; not phones' Chrome)
+ c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia
+ ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null,
+ btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
+ btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
+ }
+
// A quote inside a quote: one line of who and what, never a third level
// of cards (a bare "view note" link said nothing about where it leads).
function quoteMini(ref) {
@@ -8215,6 +10295,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'note-audio-ico', 'aria-hidden': 'true' }, '\u{1F3A7}'),
h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', class: 'small muted note-audio-name' }, name)),
h('audio', {
+ onLoadedmetadata: e => attachMediaTimeline(e.target, url),
controls: true, preload: 'none', src: url,
onPlay: (e) => { const box = e.target.closest('.note-audio'); if (box) box._skipMorph = true; },
}));
@@ -8339,7 +10420,7 @@ export function messagesFeature(ctx) {
// key and 16-byte nonce per file, the ciphertext uploaded as an opaque
// blob, the key riding inside the (already encrypted) message. The media
// host never sees a picture. coinos's own Blossom server stores only sync
- // envelopes, so these go to public hosts that take anonymous blobs.
+ // envelopes, so these go to the configured media hosts or public defaults.
const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com'];
const MEDIA_MAX = 20 * 1024 * 1024;
async function encryptFile(file) {
@@ -8366,7 +10447,7 @@ export function messagesFeature(ctx) {
const auth = id.signer instanceof Uint8Array ? finalizeEvent(evt, id.signer) : await id.signer.signEvent(evt);
const header = 'Nostr ' + btoa(JSON.stringify(auth));
const urls = [];
- for (const server of MEDIA_SERVERS) {
+ for (const server of mediaServers(MEDIA_SERVERS)) {
try {
const r = await fetch(server + '/upload', {
method: 'PUT', body: enc.cipher, signal: AbortSignal.timeout(60_000),
@@ -8538,6 +10619,14 @@ export function messagesFeature(ctx) {
} else if (/^https?:\/\//i.test(part)) {
const pack = PACK_LINK_RE.exec(part);
out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
+ } else if (/^wss?:\/\//i.test(part)) {
+ // a relay named in a post opens what's on it (the raw firehose)
+ const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part);
+ const relay = normRelay(addr);
+ out.push(relay ? h('a', {
+ href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'),
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); },
+ }, addr) : addr, tail);
} else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const bare = part.replace(/^(nostr:|@)/i, '');
const ref = parseNostrRef(bare);
@@ -8556,7 +10645,7 @@ export function messagesFeature(ctx) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref));
else if (ref && ref.type === 'addr') out.push(h('a', {
- href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
+ href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.kind === LIVE_KIND) { openLiveRef(ref); return; } const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); },
}, t('noteRefLink')));
else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…'));
} else if (/^nostr:/i.test(part)) {
@@ -8580,7 +10669,7 @@ export function messagesFeature(ctx) {
// The header magnifier's search, opened with a name already typed.
function openPeopleSearch(q) {
- ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null;
+ ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.userSearch = { q, rows: null };
warmSearch();
userSearcher.update(q);
@@ -8611,15 +10700,15 @@ export function messagesFeature(ctx) {
ui.screen = 'accounts';
render();
}
- function zapNote(pk, ev, origin) {
+ function zapNote(pk, ev, origin, moment = null) {
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { // the first tap ever sets the amount; the reader's place is kept for the way back
let scrollY = 0; try { scrollY = window.scrollY || 0; } catch {}
- ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY }; render(); return;
+ ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin, scrollY, moment }; render(); return;
}
markZapPending(ev.id, def, origin); // strike now; settlement only corrects the tally
- if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) {
+ if (!hook('zapNpub', pk, npubStr, ev.id, def, moment) && !hook('lnZapNpub', pk, npubStr, ev.id, def, moment)) {
settleZap(ev.id, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -8661,7 +10750,7 @@ export function messagesFeature(ctx) {
const { pk, npub, eventId } = s;
closeZapSetup();
markZapPending(eventId, n, s.origin);
- if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
+ if (!hook('zapNpub', pk, npub, eventId, n, s.moment) && !hook('lnZapNpub', pk, npub, eventId, n, s.moment)) {
settleZap(eventId, false);
toast('⚡ ' + t('lnZapFailed'), 4000);
}
@@ -8715,13 +10804,17 @@ export function messagesFeature(ctx) {
// A like is kind 7 on the note (NIP-25). Tapping it again withdraws it the
// only way nostr has: a deletion request for the reaction we sent.
// Reply: open (or re-aim) the thread and put the cursor in the box.
- function replyToNote(ev) {
+ function replyToNote(ev, moment = null) {
+ // the post opened from a clip as it played: a reply to it is about that moment
+ const offered = ui.noteThread && ui.noteThread.momentFor;
+ if (!moment && offered && offered.id === ev.id) moment = offered.moment;
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
} else ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev };
const s = ui.noteThread;
ui.profOverThread = false;
+ s.mediaMoment = moment;
s.replying = true; // the box appears under this note
s.scrollPending = false; // focus the composer, without a competing note scroll
s.refocus = true;
@@ -8865,7 +10958,7 @@ export function messagesFeature(ctx) {
const id = await requireIdentity();
const partial = {
kind: 7, content: emoji || '❤️', created_at: Math.floor(Date.now() / 1000),
- tags: [['e', ev.id], ['p', ev.pubkey], ...reactEmojiTags(emoji), CLIENT_TAG],
+ tags: [['e', ev.id], ['p', ev.pubkey], ...momentTags(ev.mediaMoment), ...reactEmojiTags(emoji), CLIENT_TAG],
};
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
myReactEv.set(ev.id, evt.id);
@@ -8901,7 +10994,7 @@ export function messagesFeature(ctx) {
const next = (cur ? cur + '\n\n' : '') + ref;
ui.profCompose = next;
setDraft(POST_DRAFT, next);
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.profilePk = null;
ui.chatOpen = true;
ui.msgView = 'feed';
@@ -9127,6 +11220,10 @@ export function messagesFeature(ctx) {
return null;
}
const hidden = (ev) => !!hiddenWhy(ev);
+ // A list row with nothing left to show: a post its author deleted, or a
+ // repost of one (a deletion that lands after the row did, too). Threads
+ // keep their deleted posts' places; lists just drop them.
+ const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev)));
const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
@@ -9846,16 +11943,16 @@ export function messagesFeature(ctx) {
const zapFlying = !!zp && zp.flying;
// tap pays the default amount; hold opens the screen that sets it
const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
- (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null;
+ (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null;
const zapMine = !!((z && z.mine) || optimistic);
const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle');
return h('div', { class: 'row note-acts' },
- btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }),
+ btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }),
btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})),
btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }),
// tap to choose how you feel about it; tap again to take it back
btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact,
- () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }),
+ () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }),
// always in the bar — the same row of buttons on every post, so nothing
// shifts when a wallet comes online; without one it is just the number
btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine,
@@ -9927,22 +12024,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)
@@ -9967,6 +12070,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
@@ -10006,6 +12110,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', {
@@ -10015,7 +12122,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
@@ -10030,7 +12137,9 @@ export function messagesFeature(ctx) {
zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null)
: foreign
? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText)
- : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))),
+ : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
+ momentLink(ev),
+ ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))),
foreign || pending ? null : translateLine(ev),
ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
@@ -10129,6 +12238,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;
@@ -10162,9 +12272,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;
@@ -10180,16 +12296,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.
@@ -10242,6 +12376,7 @@ export function messagesFeature(ctx) {
}
function openNoteThread(ev) {
rememberPlace('thread');
+ ui.liveStream = null;
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
@@ -10265,12 +12400,24 @@ export function messagesFeature(ctx) {
// at the top of the feed before any signing or relay round-trip (a remote
// signer alone can take seconds), swapped for the signed event on success
// and withdrawn on failure.
+ // the hashtags in the text as t tags (NIP-24), so topic feeds and other
+ // clients' searches find the post — up to the count past which a post
+ // reads as hashtag spam
+ const postHashtags = (text) => [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ function postTags(text, media, me) {
+ // NIP-92: what we uploaded, described, so clients needn't sniff the URL
+ const imeta = media.filter((m) => m && m.url).map(mediaImeta);
+ // NIP-18 quote: the reference is already in the text; the tags let a
+ // client render the quoted post inline and tell its author
+ const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
+ ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
+ // the people it mentions are told (NIP-27 p tags)
+ const mentions = mentionedPks(text).filter((pk) => pk !== me && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
+ return [...imeta, ...q, ...mentions, ...postHashtags(text).map((x) => ['t', x]), CLIENT_TAG];
+ }
async function publishPost(text, media = []) {
const id = await requireIdentity();
- // the hashtags in the text as t tags (NIP-24), so topic feeds and other
- // clients' searches find the post — up to the count past which a post
- // reads as hashtag spam
- const hashtags = [...new Set([...String(text || '').matchAll(/(^|[^\w#/&])#([\p{L}\p{N}_]{2,64})(?![\w])/gu)].map((m) => normTopic(m[2])))].slice(0, HASHTAG_MAX);
+ const hashtags = postHashtags(text);
const temp = {
id: 'pending:' + Math.random().toString(36).slice(2),
pubkey: id.pubkey, content: text, created_at: Math.floor(Date.now() / 1000),
@@ -10289,15 +12436,7 @@ export function messagesFeature(ctx) {
for (const c of feeds) c.notes = [temp, ...c.notes];
render();
try {
- // NIP-92: what we uploaded, described, so clients needn't sniff the URL
- const imeta = media.filter((m) => m && m.url).map(mediaImeta);
- // NIP-18 quote: the reference is already in the text; the tags let a
- // client render the quoted post inline and tell its author
- const q = ui.quoteOf && text.includes(ui.quoteOf.id.slice(0, 8))
- ? [] : ui.quoteOf ? [['q', ui.quoteOf.id], ['p', ui.quoteOf.pubkey]] : [];
- // the people it mentions are told (NIP-27 p tags)
- const mentions = mentionedPks(text).filter((pk) => pk !== id.pubkey && !q.some((x) => x[0] === 'p' && x[1] === pk)).map((pk) => ['p', pk]);
- const partial = { kind: 1, content: text, created_at: temp.created_at, tags: [...imeta, ...q, ...mentions, ...temp.tags, CLIENT_TAG] };
+ const partial = { kind: 1, content: text, created_at: temp.created_at, tags: postTags(text, media, id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -10313,6 +12452,93 @@ export function messagesFeature(ctx) {
}
}
+ // ---- scheduled posts -----------------------------------------------------
+ // Signed now, with created_at = when it should appear, and handed to the
+ // notifier, which holds it and publishes it then (relays refuse notes from
+ // the future). Signing now means a remote signer is asked while you're
+ // here, and the note that goes out is exactly the one you wrote. This
+ // device remembers what it scheduled, to show and cancel; the post itself
+ // turns up in the feeds once it's out.
+ const SCHED_MIN_AHEAD = 120;
+ async function schedulePost(text, media, at) {
+ const id = await requireIdentity();
+ if (!(at > Math.floor(Date.now() / 1000) + SCHED_MIN_AHEAD)) throw new Error(t('schedTooSoon'));
+ const partial = { kind: 1, content: text, created_at: at, tags: postTags(text, media, id.pubkey) };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const relays = [...new Set([...(await notesRelays(id.pubkey)), ...wallet.nostrRelays()])];
+ const res = await fetch(`${NOTIFIER}/schedule`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, relays }),
+ }).then((r) => r.json()).catch(() => ({ error: t('msgSendFailed') }));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ ui.quoteOf = null;
+ const s = st();
+ s.scheduled ||= {};
+ s.scheduled[evt.id] = { at, text, pubkey: id.pubkey };
+ save(s);
+ render();
+ return evt;
+ }
+ // what this account has waiting, soonest first; anything past its time
+ // has gone out (or the notifier gave up) and leaves the list
+ function scheduledPosts() {
+ const s = st(), me = mePk(), now = Math.floor(Date.now() / 1000);
+ let dropped = false;
+ for (const [k, v] of Object.entries(s.scheduled || {})) if (v.at < now - 60) { delete s.scheduled[k]; dropped = true; }
+ if (dropped) save(s);
+ return Object.entries(s.scheduled || {}).filter(([, v]) => v.pubkey === me)
+ .map(([id, v]) => ({ id, ...v })).sort((a, b) => a.at - b.at);
+ }
+ // once a session, ask which are still held: one cancelled on another
+ // device is gone from the notifier, so it goes from here too
+ let schedChecked = false;
+ function checkScheduled(list) {
+ if (schedChecked || !list.length) return;
+ schedChecked = true;
+ fetch(`${NOTIFIER}/schedule/status?ids=${list.map((x) => x.id).join(',')}`).then((r) => r.json()).then((st_) => {
+ const s = st();
+ let n = 0;
+ for (const [k, v] of Object.entries(st_ || {})) if (v === 'gone' && s.scheduled?.[k]) { delete s.scheduled[k]; n++; }
+ if (n) { save(s); render(); }
+ }).catch(() => {});
+ }
+ async function cancelScheduled(item) {
+ const id = await requireIdentity();
+ const partial = { kind: 5, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', item.id]] };
+ const del = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const res = await fetch(`${NOTIFIER}/schedule/cancel`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: del }),
+ }).then((r) => r.json()).catch(() => ({}));
+ if (!res.ok) throw new Error(res.error || t('msgSendFailed'));
+ const s = st();
+ delete s.scheduled?.[item.id];
+ save(s);
+ render();
+ }
+ const schedWhen = (at) => new Date(at * 1000).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ // <input type=datetime-local> speaks local wall-clock time
+ const localInput = (ms) => { const d = new Date(ms); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); };
+ function scheduledList() {
+ const list = scheduledPosts();
+ checkScheduled(list);
+ if (!list.length) return null;
+ return h('div', { class: 'card col sched-list', 'data-key': 'sched-list', style: 'gap:8px' },
+ h('div', { class: 'small muted' }, t('schedListTitle', { n: list.length })),
+ ...list.map((x) => h('div', { class: 'row gap6 sched-item', 'data-key': 'sched:' + x.id, style: 'align-items:flex-start' },
+ h('div', { class: 'col grow', style: 'min-width:0;gap:2px' },
+ h('div', { class: 'small', style: 'font-weight:600' }, '\u{1F552} ' + schedWhen(x.at)),
+ h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, x.text)),
+ h('button', {
+ class: 'btn-ghost btn-sm', disabled: ui.schedCancelling === x.id,
+ onClick: async () => {
+ ui.schedCancelling = x.id; render();
+ try { await cancelScheduled(x); toast(t('schedCancelled')); }
+ catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.schedCancelling = null; render();
+ },
+ }, ui.schedCancelling === x.id ? h('span', { class: 'spinner sm' }) : t('cancel')))));
+ }
+
async function publishReply(c, s, text) {
const id = await requireIdentity();
const target = c.replies.find((e) => e.id === s.focusId) || c.root || s.seed;
@@ -10358,6 +12584,8 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
+ note.tags.push(...momentTags(s.mediaMoment));
+ if (s.mediaMoment) note.content = '[' + mediaClock(s.mediaMoment.seconds) + '] ' + note.content;
const evt = id.signer instanceof Uint8Array ? finalizeEvent(note, id.signer) : await id.signer.signEvent(note);
const relays = [...new Set([...(await notesRelays(target.pubkey)), ...wallet.nostrRelays()])];
const ok = await publishOn(relays, evt);
@@ -10368,9 +12596,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));
@@ -10392,6 +12652,11 @@ export function messagesFeature(ctx) {
class: 'col thread-reply' + (joined ? ' joined' : ''),
style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'),
},
+ // the moment of the clip this reply is about, and a way to make it about the whole post
+ s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' },
+ t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds),
+ h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'),
+ style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -10416,7 +12681,9 @@ export function messagesFeature(ctx) {
onKeydown: (e) => {
mentionKey(e);
if (e.defaultPrevented) return;
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
+ // a phone's Return is a new line (Reply is the button); a
+ // keyboard's Enter sends
+ if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; }
if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
const el = e.target;
@@ -10457,7 +12724,19 @@ export function messagesFeature(ctx) {
previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }),
h('button', {
class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button',
- onClick: () => { s.replying = false; s.preview = false; render(); },
+ // Cancel discards: the typed text, its attachments and the box.
+ // (It only folded the `replying` flag before, and the box stays
+ // up while a draft has text — so with anything typed it did
+ // nothing visible.) The field may still be focused, and the
+ // morph leaves a focused field's value alone: clear it by hand.
+ onClick: () => {
+ const text = s.draft || '';
+ s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null;
+ ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url));
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = '';
+ render();
+ },
}, t('cancel'))),
h('button', {
class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading,
@@ -10473,7 +12752,7 @@ export function messagesFeature(ctx) {
// focused field's value — clear it by hand
const inp = document.querySelector('.thread-reply-input');
if (inp) inp.value = '';
- s.replying = false; s.preview = false; // sent: the box folds away
+ s.replying = false; s.preview = false; s.mediaMoment = null; // sent: the box folds away
toast(t('threadReplied'));
} catch (e) { if (!e.silent) toast(e.message); }
s.sending = false; render();
@@ -10509,23 +12788,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;
@@ -10535,8 +12800,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
@@ -10563,17 +12830,52 @@ export function messagesFeature(ctx) {
// (or profile) and its scroll place. A thread opened straight from a
// link has nothing of ours under it, so no button that invents one.
!ctx.canGoBack || ctx.canGoBack()
- ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; }) }, t('back'))
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.noteThread = null; ui.liveStream = null; ui.goLive = null; }) }, t('back'))
: null,
...noteOverlays());
}
- async function publishProfileFields(fields, opts = {}) {
+ // Every kind-0 publish merges over the newest copy on the relays, so two
+ // publishes in flight at once clobber each other: both read the same base,
+ // the later one lands without the earlier one's fields. That exact race
+ // put a migrated user's released placeholder name back into their lud16
+ // (the rename repointed it, the legacy-avatar import republished the old
+ // base nine seconds later). One at a time, and the base is never older
+ // than what this session last wrote.
+ let kind0Chain = Promise.resolve();
+ let kind0Last = null; // { at, content } — the newest kind 0 we published
+ const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; };
+ // A kind 0 is replaceable too: one built on a profile we failed to find
+ // wipes the real one wherever it lands. 2026-10-30, a long-time nostr
+ // user signed in with his key for the first time; our relays had never
+ // seen his kind 0 (it lived on ditto, snort and oxtr), the lookup below
+ // came back empty, and the payment-address publish went out as
+ // {lud16, nip05} alone — name, picture and banner gone everywhere. So an
+ // empty quick lookup widens to the same search the follow list gets
+ // (indexes, archives, his own write relays, Primal's signed copy) before
+ // anything is written. Once per key per session: a key with no profile
+ // anywhere is new, and its first publish shouldn't wait twice.
+ const kind0DeepMiss = new Set();
+ async function newestKind0(pk) {
+ const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000);
+ let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null;
+ if (!newest && !(kind0Last && kind0Last.pk === pk) && !kind0DeepMiss.has(pk)) {
+ try { newest = (await deepContactList(pk)).profile || null; } catch {}
+ if (!newest) kind0DeepMiss.add(pk);
+ }
+ if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last;
+ let base = {};
+ try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ return base;
+ }
+ // Strictly later than the last one we sent: relays keep the LOWEST id of
+ // two replaceable events with the same second, so a second publish in
+ // the same second as the first could lose to it.
+ const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; };
+ function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); }
+ async function publishProfileFieldsNow(fields, opts = {}) {
const id = await requireIdentity();
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be
const merged = { ...base };
// fillOnly fields are offers, not orders: the onboarding wizard suggests
@@ -10589,10 +12891,11 @@ export function messagesFeature(ctx) {
}
if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name;
if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
return true;
@@ -10606,11 +12909,20 @@ export function messagesFeature(ctx) {
ui.profSaving = true;
render();
try {
+ await withKind0Lock(() => saveProfileNow(id, e));
+ ui.profEdit = null; ui.profEditFilled = false;
+ toast(t('profSaved'));
+ } catch (err) {
+ if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
+ toast(err.message || String(err));
+ } finally {
+ ui.profSaving = false;
+ render();
+ }
+ }
+ async function saveProfileNow(id, e) {
// merge over the newest published kind 0 so unknown fields round-trip
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
// A changed username claims first — if the name is taken (or invalid)
// the save stops here and the editor stays open to fix it.
const oldAddr = hook('namesAddress');
@@ -10630,26 +12942,134 @@ export function messagesFeature(ctx) {
// when empty; move them when they pointed at our (old) address. A
// deliberately foreign lud16/nip05 is the user's business.
if (addr) {
- for (const k of ['lud16', 'nip05'])
- if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
+ const domain = addr.split('@')[1];
+ for (const k of ['lud16', 'nip05']) {
+ const cur = merged[k];
+ if (!cur || cur === oldAddr) { merged[k] = addr; continue; }
+ // a name at our own domain that is not ours and no longer answers
+ // (the placeholder a migration released) is ours now — Save is the
+ // heal an interactive signer can't do in the background
+ if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr;
+ }
}
if (merged.name) merged.display_name = merged.name;
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
- ui.profEdit = null; ui.profEditFilled = false;
- toast(t('profSaved'));
- } catch (err) {
- if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
- toast(err.message || String(err));
- } finally {
- ui.profSaving = false;
+ }
+ // ---- profile history: undo a kind 0 that replaced the real one ---------
+ // Relays holding the newest copy dropped the older ones; the archives
+ // often haven't. An older copy fills the editor's EMPTY fields (name,
+ // bio, picture, banner) — nothing typed or already set is touched, and
+ // nothing is published until Save.
+ const PROFILE_HISTORY_FIELDS = ['name', 'about', 'picture', 'banner'];
+ async function findProfileHistory() {
+ const me = mePk();
+ if (!me) return [];
+ const relays = [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...PROFILE_ARCHIVE_RELAYS, ...FOLLOW_HISTORY_RELAYS,
+ ...(await relaysOf(me).catch(() => []))])];
+ const evs = await queryOn(relays, { kinds: [0], authors: [me], limit: 50 }, 8000).catch(() => []);
+ const byId = new Map();
+ for (const e of evs || []) if (e.pubkey === me) byId.set(e.id, e);
+ return [...byId.values()].sort((a, b) => b.created_at - a.created_at);
+ }
+ const profileFieldsOf = (e) => {
+ let c = {};
+ try { c = JSON.parse(e.content) || {}; } catch {}
+ const out = {};
+ for (const k of PROFILE_HISTORY_FIELDS) {
+ const v = k === 'name' ? (c.display_name || c.name) : c[k];
+ if (typeof v === 'string' && v.trim()) out[k] = v.trim();
+ }
+ return out;
+ };
+ // what an older copy has that the draft is missing
+ const profileHistoryExtra = (e, draft) => Object.keys(profileFieldsOf(e)).filter((k) => !(draft && String(draft[k] || '').trim()));
+ function fillProfileFrom(e) {
+ const draft = ui.profEdit;
+ if (!draft) return 0;
+ const old = profileFieldsOf(e);
+ let n = 0;
+ for (const k of profileHistoryExtra(e, draft)) { draft[k] = old[k]; (draft.touched ||= {})[k] = true; n++; }
+ if (n) {
+ if (old.picture && draft.picture === old.picture) preloadPicture({ picture: old.picture });
+ toast(t('profHistFilled', { n }));
+ } else toast(t('profHistNothing'));
+ if (ui.profHist) ui.profHist.done = true;
+ render();
+ return n;
+ }
+ function profileHistoryCard() {
+ const draft = ui.profEdit;
+ if (!draft || !mePk()) return null;
+ const ph = ui.profHist;
+ const look = () => {
+ ui.profHist = { loading: true };
render();
+ findProfileHistory().then((vs) => { ui.profHist = { versions: vs }; render(); })
+ .catch(() => { ui.profHist = { versions: [] }; render(); });
+ };
+ const versions = (ph && ph.versions) || [];
+ const offers = versions.map((e) => ({ e, extra: profileHistoryExtra(e, draft) })).filter((v) => v.extra.length);
+ return h('div', { class: 'col prof-hist-card', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('profHistDesc')),
+ !ph || ph.done
+ ? h('button', { class: 'btn-sm', type: 'button', style: 'align-self:flex-start', onClick: look }, t('profHistLook'))
+ : ph.loading
+ ? h('div', { class: 'row', style: 'justify-content:center;padding:6px' }, h('span', { class: 'spinner sm' }))
+ : offers.length
+ ? h('div', { class: 'col', style: 'gap:8px' }, ...offers.slice(0, 6).map(({ e, extra }) => {
+ const f = profileFieldsOf(e);
+ return h('div', { class: 'row between gap6', style: 'align-items:center' },
+ h('div', { class: 'col', style: 'gap:1px;min-width:0' },
+ h('div', { class: 'break' }, f.name || t('profHistNoName')),
+ h('div', { class: 'small faint' }, new Date(e.created_at * 1000).toLocaleDateString()
+ + ' · ' + extra.map((k) => t('profHist_' + k)).join(', '))),
+ h('button', { class: 'btn-sm', type: 'button', onClick: () => fillProfileFrom(e) }, t('profHistUse')));
+ }))
+ : h('div', { class: 'small faint' }, t('profHistNone')));
+ }
+ // A payment-address rename released the old name: repoint the kind 0's
+ // lud16 and nip05 — but only where they pointed at the released address
+ // (or were empty). Deliberately different values are not ours to touch.
+ function repointAddress(oldAddr, newAddr) {
+ const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
+ return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
+ onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
+ fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
+ });
+ }
+ // Our kind 0 still names an address at our own domain that is not the one
+ // we hold — a placeholder name released by a migration whose repoint lost
+ // the race above. Repointed once the domain confirms the old name is dead;
+ // a same-domain name that still answers (a second account, say) is left
+ // alone. Once per address per session.
+ const addrHealed = new Set();
+ async function healStaleAddress(olds, myAddr) {
+ // Amber by intent needs a tap per signature: a background publish would
+ // sit in the kind-0 queue forever and block every later one. Save in the
+ // profile editor repoints the address for these users instead.
+ const id = await identity().catch(() => null);
+ if (!id || (id.signer && id.signer.interactive)) return;
+ for (const old of olds) {
+ if (addrHealed.has(old)) continue;
+ addrHealed.add(old);
+ try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {}
}
}
+ async function addressDead(addr) {
+ const [name, domain] = addr.split('@');
+ try {
+ const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' });
+ if (!r.ok) return true;
+ const j = await r.json().catch(() => null);
+ return !j || j.status === 'ERROR' || !j.callback;
+ } catch { return false; } // unreachable is not dead
+ }
function profileScreen() {
const pk = ui.profilePk;
@@ -10789,9 +13209,17 @@ export function messagesFeature(ctx) {
// authoritative locally, a deep link carries the address in the URL
// (seeded as `addr`), and the light cache remembers it from last time.
const lp = profileOf(pk) || {};
- const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null;
+ // Our own page never shows a name at our domain that the registrar says
+ // is no longer ours (see healStaleAddress): the claimed address is the
+ // truth here, and the kind 0 is brought in line in the background.
+ const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null;
+ if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); }
+ const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
- const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ // the lightning line too: a stale same-domain lud16 (jar@ after the
+ // rename to jarryd@) showed under the right NIP-05 as a second address
+ const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
+ const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
// an about of "~" or a lone character is noise, not a bio.
// Until the full kind 0 lands, the light profile cache answers — it was
@@ -10842,6 +13270,9 @@ export function messagesFeature(ctx) {
showLud ? h('div', { class: 'muted small break' }, '⚡ ' + lud16) : null,
// the hat pitch lives beside the avatar it decorates
mine ? hook('hatShopEntry') : null)),
+ ui.profEdit ? null : liveBanner(pk),
+ mine && !ui.profEdit && canGoLive() && !liveNowOf(pk) ? h('button', { class: 'btn-sm go-live-btn', style: 'align-self:flex-start', onClick: openGoLive },
+ h('span', { class: 'go-live-dot' }), t('goLive')) : null,
// no spinner while the kind 0 loads — prefetch keeps this rare, and
// an empty beat reads calmer than a spinner
// The bio rides the same renderer as notes: npub mentions become
@@ -10931,6 +13362,7 @@ export function messagesFeature(ctx) {
ui.profUploading = false; render();
},
})),
+ profileHistoryCard(),
h('button', { class: 'btn-primary btn-block', disabled: ui.profSaving, onClick: saveProfile },
ui.profSaving ? h('span', { class: 'spinner sm' }) : t('save')),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profEdit = null; ui.profEditFilled = false; render(); } }, t('cancel')))
@@ -10944,6 +13376,7 @@ export function messagesFeature(ctx) {
// the half-written post back, composer open. Posting clears
// it; Cancel just closes the composer and keeps the text.
postComposer(),
+ ui.profCompose == null ? scheduledList() : null,
// the non-destructive way out: add another identity (Nostr,
// passkey, Google) next to this one and switch between them
// on the Accounts screen — logout stays for actually leaving
@@ -10968,7 +13401,7 @@ export function messagesFeature(ctx) {
// screen router shows an open thread before the chat, so
// a reply half-typed under their post used to win here
ui.profilePk = null; ui.profOverThread = false;
- ui.noteThread = null; ui.userSearch = null; ui.feedEdit = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = null; ui.feedEdit = null;
ui.chatOpen = true;
ui.msgView = 'dm';
ui.msgPeer = peer;
@@ -10982,7 +13415,7 @@ export function messagesFeature(ctx) {
// and the Send tab would never appear
ui.profilePk = null;
ui.profOverThread = false;
- ui.noteThread = null;
+ ui.noteThread = null; ui.liveStream = null; ui.goLive = null;
ui.userSearch = null;
ui.chatOpen = false;
ctx.showSend({ fresh: true });
@@ -11029,23 +13462,27 @@ export function messagesFeature(ctx) {
const c = notesFor(pk);
// Posts | Replies: the replies are their side of other people's
// conversations, apart from what they posted themselves
- const tab = profTab(pk);
- const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
+ // Streams joins the tabs once they've streamed
+ const streamed = pastOf(pk).length > 0;
+ const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
+ // a post its author deleted leaves the list (no "was deleted" row)
+ const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
// too short to scroll means the scroll that loads more never comes
// (five pages at most per tab: someone who never replies must not
// have their whole history paged through looking for one)
const fill = c.autoFill && c.autoFill.tab === tab ? c.autoFill : (c.autoFill = { tab, n: 0 });
- if (c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
+ if (tab !== 'streams' && c.status === 'ready' && shown.length < 12 && !c.end && !c.loadingMore && fill.n < 5) {
fill.n++;
queueMicrotask(() => loadOlderNotes(pk).catch(() => {}));
}
const tabs = h('div', { class: 'seg prof-tabs', style: 'display:flex;width:100%' },
- ...['posts', 'replies'].map((k) => h('button', {
+ ...['posts', 'replies', ...(streamed ? ['streams'] : [])].map((k) => h('button', {
type: 'button', class: (tab === k ? 'active ' : '') + 'grow', 'data-tab': k,
onClick: () => { if (tab === k) return; ui.profTab = { pk, tab: k }; c.win = null; render(); },
- }, t(k === 'posts' ? 'profTabPosts' : 'profTabReplies'))));
+ }, t(k === 'posts' ? 'profTabPosts' : k === 'replies' ? 'profTabReplies' : 'profTabStreams'))));
+ if (tab === 'streams') return h('div', { class: 'col', style: 'gap:8px' }, tabs, pastStreams(pk));
// while loading with nothing to show: an invisible copy of the
// empty-state line holds the height, so the page doesn't jump when
// the answer lands (no spinner). With posts already in hand — from
@@ -11062,7 +13499,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) && !goneHere(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' }))
@@ -11090,11 +13527,13 @@ export function messagesFeature(ctx) {
if (!file || !upload) return;
ui.postUploading = true; render();
try {
- const url = await upload(file);
+ // (a URL, or what a stream's upload says of it: its type, size, length)
+ const got = await upload(file);
+ const url = typeof got === 'string' ? got : got.url;
// A post canceled while its upload was in flight must stay canceled.
// `false` lets that composer reject the late result without affecting
// the reply composer, which shares this helper.
- if (place(url) !== false) (ui.postMedia ||= []).push({ url, m: file.type || '' });
+ if (place(url) !== false) (ui.postMedia ||= []).push(typeof got === 'string' ? { url, m: file.type || '' } : { m: file.type || '', ...got });
} catch (e) { toast(e.message || String(e)); }
ui.postUploading = false; render();
}
@@ -11118,6 +13557,7 @@ export function messagesFeature(ctx) {
ui.profCompose = null;
ui.postMedia = [];
ui.postPreview = false;
+ ui.postSchedule = null;
discardDraft(POST_DRAFT);
render();
}
@@ -11207,15 +13647,40 @@ export function messagesFeature(ctx) {
type: 'button', class: 'compose-tool', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('post-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
- gifs.button('post', 'compose-tool gif-btn'))),
+ gifs.button('post', 'compose-tool gif-btn'),
+ canGoLive() ? h('button', { type: 'button', class: 'compose-tool go-live-tool', title: t('goLive'), 'aria-label': t('goLive'), onClick: openGoLive },
+ h('span', { class: 'go-live-dot' })) : null)),
mentionList('.post-input'),
ui.postPreview ? draftPreview(text) : null,
mediaBar(),
+ ui.postSchedule != null ? h('label', { class: 'row gap6 sched-pick', style: 'align-items:center' },
+ h('span', { class: 'small muted' }, t('schedAt')),
+ h('input', {
+ type: 'datetime-local', class: 'grow', value: ui.postSchedule,
+ min: localInput(Date.now() + SCHED_MIN_AHEAD * 1000), max: localInput(Date.now() + 90 * 86400_000),
+ onInput: (ev) => { ui.postSchedule = ev.target.value; },
+ })) : null,
h('div', { class: 'row gap6 wrap post-actions' },
- h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
+ h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading || !!ui.postScheduling, onClick: async () => {
const body = composeText().trim();
if (!body) return;
const media = (ui.postMedia || []).filter((m) => body.includes(m.url));
+ if (ui.postSchedule != null) {
+ // nothing goes on screen yet, so the composer stays open until
+ // the notifier has it (a signer may ask first)
+ const at = Math.floor(new Date(ui.postSchedule).getTime() / 1000);
+ ui.postScheduling = true; render();
+ try {
+ await schedulePost(body, media, at);
+ ui.profCompose = null; ui.postMedia = []; ui.postSchedule = null;
+ setDraft(POST_DRAFT, '');
+ const inp = document.querySelector('.post-input');
+ if (inp) inp.value = '';
+ toast(t('schedDone', { when: schedWhen(at) }));
+ } catch (e) { if (!e.silent) toast(e.message || String(e)); }
+ ui.postScheduling = false; render();
+ return;
+ }
// the post is on screen instantly (publishPost is optimistic) —
// close the composer now; a failure reopens it with the text intact
ui.profCompose = null;
@@ -11229,14 +13694,21 @@ export function messagesFeature(ctx) {
if (!e.silent) toast(e.message || String(e));
render();
}
- } }, t('profPostBtn')),
+ } }, ui.postScheduling ? h('span', { class: 'spinner sm' }) : t(ui.postSchedule != null ? 'schedBtn' : 'profPostBtn')),
h('input', {
type: 'file', id: 'post-file', accept: 'image/*,video/*', style: 'display:none',
onChange: async (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; await attachMedia(f); },
}),
h('div', { class: 'row gap6 composer-options' },
previewBtn(!!ui.postPreview, () => { ui.postPreview = !ui.postPreview; render(); }),
+ h('button', {
+ type: 'button', class: 'btn-ghost btn-sm', title: t('schedToggle'), 'aria-label': t('schedToggle'),
+ 'aria-pressed': String(ui.postSchedule != null),
+ // an hour from now, on the next quarter hour
+ onClick: () => { ui.postSchedule = ui.postSchedule != null ? null : localInput(Math.ceil((Date.now() + 3600_000) / 900_000) * 900_000); render(); },
+ }, '\u{1F552}'),
h('button', { class: 'btn-ghost btn-sm', onClick: requestCancelPost }, t('cancel')))),
+ scheduledList(),
postCancelWarning(),
gifs.sheet('post', (g) => {
const cur = composeText().replace(/\s+$/, '');
@@ -11268,10 +13740,13 @@ export function messagesFeature(ctx) {
function feedChips() {
const items = feedList();
const cur = feedDef();
- if (cur && adhocFeeds.has(cur.id)) items.push(cur);
+ // (a wallet restoring behind /feed is on the visitor's copy of Popular
+ // for a beat: the built-in chip stands for it, not a second Popular)
+ const onVisitorCopy = curFeedId === 'all:visitor';
+ if (cur && adhocFeeds.has(cur.id) && !onVisitorCopy) items.push(cur);
return h('div', { class: 'row feed-chips', 'data-key': 'feed-chips' },
...items.map((f) => h('button', {
- class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id,
+ class: 'feed-chip' + (f.id === curFeedId || (onVisitorCopy && f.id === EVERYTHING) ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id,
onClick: () => switchFeed(f.id),
// someone's timeline carries the person, not a name — an empty
// label left a blank black pill
@@ -11319,7 +13794,7 @@ export function messagesFeature(ctx) {
function toFrontDoor() {
rememberReturn();
ui.signinAsk = null; ui.pubProf = null; ui.chatOpen = false; ui.msgView = null;
- ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.feedRelayEdit = null;
+ ui.userSearch = null; ui.profilePk = null; ui.profOverThread = false; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.feedRelayEdit = null;
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -11350,14 +13825,17 @@ export function messagesFeature(ctx) {
}, 0);
}
function appBottomNav() {
- const visitor = isVisitor();
+ const visitor = visitorView();
const me = !visitor && ctx.shownPubkey ? ctx.shownPubkey() : null;
- const active = ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ // a stream (or Go live) over the shell is a place of its own: no button
+ // is "here", so the one tapped goes there (Feed under a stream thought it
+ // was home and only scrolled the hidden feed)
+ const active = ui.liveStream || ui.goLive ? 'live' : ui.userSearch ? 'search' : me && ui.profilePk === me && !ui.noteThread ? 'profile' : ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : ui.tab === 'settings' ? 'settings' : 'wallet';
const leavePage = () => {
stopFeedWatch(); stopNotifWatch();
// Top-level destinations must leave any feature page covering the shell.
- for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'userSearch',
+ for (const key of ['profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'noteThread', 'liveStream', 'goLive', 'liveReport', 'userSearch',
'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet',
'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen',
'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null;
@@ -11438,7 +13916,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 === 'live' || 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) => {
@@ -11463,7 +13944,9 @@ 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 };
+ const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 });
+ 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,
+ order: { images: order(), videos: order() } };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -11474,17 +13957,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;
}
@@ -11493,7 +13972,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
@@ -11502,7 +13981,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);
@@ -11517,6 +13996,10 @@ export function messagesFeature(ctx) {
if (!def || !feedHasQuery(def)) return Promise.resolve();
if (def.curated) return loadOlderFeed(); // a curated feed pages its own way
const r = mediaIndex(c.id);
+ // A grid is never at its end (Adam, 2026-10-17: "I should never be all
+ // caught up"): the bottom it found — the relays answering empty — is
+ // looked past again a few minutes on.
+ if (r.end && Date.now() - r.end > 300_000) { r.end = false; r.empty = 0; }
if (!r.loaded || r.end || mediaCrawling) return mediaCrawling || Promise.resolve();
mediaCrawling = (async () => {
scheduleRepaint();
@@ -11533,9 +14016,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; }
@@ -11655,7 +14142,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);
@@ -11686,12 +14173,17 @@ export function messagesFeature(ctx) {
// <img> of the original (or the 1100px column copy) to fetch and decode,
// and the browser's lazy margin is a screen or two. These are local and a
// few KB, so every tile loads at once and decodes in no time.
- const TILE_CACHE = 'coinos-tile-thumbs-v1', TILE_PX = 360, TILE_MAX = 1200;
+ const TILE_CACHE = 'coinos-tile-thumbs-v2', TILE_PX = 360, TILE_MAX = 1200;
const tileThumbs = new Map(); // url -> object URL
const tileKey = (url) => 'https://tile-thumb.invalid/?u=' + encodeURIComponent(url);
const tileQueue = [], tileTried = new Set(), tileLooked = new Set();
let tileBusy = 0, tileRepaint = null;
- const tileStore = () => caches.open(TILE_CACHE);
+ let tileOldGone = false;
+ const tileStore = () => {
+ // the square tiles of before (v1) go: the grid's rows want each shape
+ if (!tileOldGone) { tileOldGone = true; caches.delete('coinos-tile-thumbs-v1').catch(() => {}); }
+ return caches.open(TILE_CACHE);
+ };
async function warmTileThumbs(urls) {
if (typeof caches === 'undefined') return;
let found = 0;
@@ -11711,7 +14203,7 @@ export function messagesFeature(ctx) {
const kept = mediaReady.get(url)?.thumb;
if (!kept && (noCorsHost(url) || corsRefused.has(url))) {
// the host keeps its bytes: the server cuts the tile
- const tile = await serverPicBlob(url, 'tile');
+ const tile = await serverPicBlob(url, 'row');
if (tile) await saveTileThumb(url, tile);
return;
}
@@ -11721,7 +14213,7 @@ export function messagesFeature(ctx) {
if (!res.ok) return;
blob = await res.blob();
} catch (e) {
- if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); }
+ if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); }
return;
}
if (/gif/i.test(blob.type)) return; // animated: the tile shows the original
@@ -11767,6 +14259,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();
@@ -11806,11 +14360,77 @@ 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".)
+ // On a desktop-sized window every shape is the default (Adam, 2026-10-22:
+ // the justified rows hold both); a phone keeps to its way round.
+ const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', 'fit' (the screen's way round), or unset: the device's default
+ const bigWindow = () => { try { return matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; } catch { return false; } };
+ const clipsAll = () => { try { const v = localStorage.getItem(CLIPS_FIT_KEY); return v === 'all' || (v !== 'fit' && bigWindow()); } catch { return bigWindow(); } };
+ const setClipsAll = (all) => { try { if (all === bigWindow()) localStorage.removeItem(CLIPS_FIT_KEY); else localStorage.setItem(CLIPS_FIT_KEY, all ? 'all' : 'fit'); } catch {} };
+ const screenTall = () => window.innerHeight >= window.innerWidth;
+ const clipSize = (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 ? { w, h: hh } : null;
+ };
+ // (no size yet: the still's proportions, which a grid of every shape has
+ // loaded without measuring anything — so going back to one shape finds
+ // the clips' shapes known)
+ const clipShape = (url) => {
+ const z = clipSize(url), r = z ? z.w / z.h : posterAr.get(url);
+ return r ? (r < 0.99 ? 'tall' : r > 1.01 ? 'wide' : 'square') : null;
+ };
+ // width over height, kept between a phone's upright 9:16 and a cinema 2:1
+ // (a stray 32:9 or 1:4 would make a tile a sliver); unknown: a square
+ // (until the clip's size is known, the still's own proportions: the
+ // server's and a frame grab keep the clip's shape)
+ const posterAr = new Map();
+ const clampAr = (r) => Math.min(2, Math.max(0.5, r));
+ const clipRatio = (url) => { const z = clipSize(url); return z ? clampAr(z.w / z.h) : posterAr.get(url) || 1; };
+ // a grid item's: the file's measured size, the post's word for it (NIP-92
+ // dim), or its still once that has loaded
+ function itemRatio(it) {
+ if (clipSize(it.url)) return clipRatio(it.url);
+ for (const tag of it.ev.tags || []) {
+ if (tag[0] !== 'imeta' || !tag.includes('url ' + it.url)) continue;
+ const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(imetaFields(tag).dim || '');
+ if (m && +m[1] && +m[2]) return clampAr(+m[1] / +m[2]);
+ }
+ return posterAr.get(it.url) || 1;
+ }
+ function arFromStill(img, url) {
+ if (clipSize(url) || !img.naturalWidth || !img.naturalHeight) return;
+ const r = clampAr(img.naturalWidth / img.naturalHeight);
+ posterAr.set(url, r);
+ const tile = img.closest('.media-grid.justified .media-tile');
+ if (tile) tile.style.setProperty('--ar', r.toFixed(3));
+ }
+ // 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;
}
@@ -11822,8 +14442,17 @@ export function messagesFeature(ctx) {
if (d && d.probed && !d.w && d.ranges && !videoDims.has(url)) return playerShape(url);
}).finally(() => { if (mediaMode === 'videos') scheduleRepaint(); });
}
- function mediaItems(c, kind) {
- const out = [], seen = new Set();
+ // One picture or clip shows once in the grid, however many posts carry it
+ // (Adam, 2026-10-22: a conference promo filled the Videos view with
+ // copies): the same file (a Blossom hash names it wherever it's hosted),
+ // or the same words from the same person posted again with another upload.
+ const sameFile = (url) => { const m = /\/([0-9a-f]{64})(?:\.\w+)?(?:[?#]|$)/i.exec(url); return m ? m[1].toLowerCase() : url.replace(/[?#].*$/, ''); };
+ const saidAgain = (ev) => { const w = String(ev.content || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim(); return w.length >= 8 ? ev.pubkey + '|' + w : null; };
+ // (anyShape: the pager's list — every clip, the ones the wrong way round
+ // for the screen held out of sight by pagerFit, so "All shapes" there has
+ // them to show)
+ function mediaItems(c, kind, anyShape = false) {
+ const out = [], seen = new Set(), said = new Map();
const r = mediaIndex(c.id);
indexMedia(r, c.notes);
const evs = [...r[kind].values()].sort((a, b) => b.created_at - a.created_at);
@@ -11832,17 +14461,51 @@ export function messagesFeature(ctx) {
if (hidden(ev)) continue;
// 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;
+ const again = saidAgain(ev);
+ if (again && said.has(again) && said.get(again) !== ev.id) continue;
if (kind === 'videos') learnVideoMeta(ev);
- const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip)
+ // (a clip whose host says it's deleted has nothing to show: a blank tile)
+ const urls = kind === 'videos' ? clipUrlsOf(ev).filter((u) => !clipGone(u) && (anyShape || fitClip(u)))
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
+ // (the words are taken by a post that shows: one whose clip doesn't fit
+ // the screen leaves them to the next)
+ if (again && urls.length) said.set(again, ev.id);
for (const url of urls) {
- const it = { ev, url };
- if (seen.has(mediaKey(it))) continue;
- seen.add(mediaKey(it));
- out.push(it);
+ if (deadTiles.has(url)) continue;
+ const f = sameFile(url);
+ if (seen.has(f)) continue;
+ seen.add(f);
+ // the same clip uploaded twice by one person (a note and a video
+ // event a few minutes apart) gets two hashes but keeps its size to
+ // the byte
+ const d = kind === 'videos' ? videoInfo.get(url) || {} : {};
+ const twin = d.size && !d.sizeGuess ? ev.pubkey + '|' + d.size : null;
+ if (twin && seen.has(twin)) continue;
+ if (twin) seen.add(twin);
+ out.push({ ev, url });
}
}
- return out;
+ return settledOrder(r, kind, out);
+ }
+ // The grid's order holds for the session (Adam, 2026-10-23: clips the
+ // crawl found landed in the middle of the grid, above an older tail the
+ // index had from before, and the tiles already there jumped): what it
+ // shows first is newest first, and whatever turns up after goes under what
+ // is already there, in its own date order. Only a post newer than the top
+ // tile goes above it. Nothing is settled until the kept index is in, so
+ // the first paint is one date order, not two batches.
+ function settledOrder(r, kind, out) {
+ if (!r.loaded) return out;
+ const o = r.order[kind];
+ const key = (it) => it.ev.id + '|' + it.url;
+ const fresh = out.filter((it) => !o.seq.has(key(it)));
+ if (fresh.length) {
+ const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : [];
+ for (const it of above.reverse()) o.seq.set(key(it), --o.top);
+ for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom);
+ for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at;
+ }
+ return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b)));
}
// The feed's pictures (or clips) in reading order — every one of a post
// before the next post's — for the pager opened from one in the feed: the
@@ -11884,11 +14547,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;
@@ -11898,6 +14572,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,
@@ -11908,14 +14589,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);
@@ -11925,16 +14609,26 @@ export function messagesFeature(ctx) {
// (what the player's own full screen did). The screen isn't turned for a
// wide clip — Adam would rather it play with bars and turn the phone
// himself — so it follows the phone throughout.
- function openClip(v, url) {
+ // The pager's item for a clip on the page, if the pager has it: a clip in
+ // the post a reply answers (its "Replying to" card) isn't one of the
+ // feed's own, and gets the browser's full screen instead.
+ function clipItem(v, url) {
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);
- if (!it) return false;
+ if (!on) return null;
+ const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url);
+ return it ? { on, it } : null;
+ } catch { return null; }
+ }
+ function openClip(v, url) {
+ try {
+ const found = clipItem(v, url);
+ if (!found) return false;
+ const { on, it } = found;
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; }
@@ -11965,15 +14659,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 {}
}
@@ -11982,8 +14699,13 @@ export function messagesFeature(ctx) {
class: 'feed-chip media-mode' + (mediaMode === m ? ' on' : ''), type: 'button', 'data-mode': m,
onClick: () => setMediaMode(m),
}, label);
+ // Live wears a red dot while someone in the feed is streaming
+ const def = feedDef(curFeedId);
+ const onAir = def && liveForFeed(def).length > 0;
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('live', onAir ? [h('span', { class: 'live-dot', 'aria-hidden': 'true' }), t('mediaLive')] : t('mediaLive')),
+ b('articles', t('mediaArticles')));
}
function mediaGrid(c) {
const items = mediaItems(c, mediaMode);
@@ -12000,7 +14722,16 @@ export function messagesFeature(ctx) {
for (const u of missing) tileLooked.add(u);
if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u))));
}
- return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
+ if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
+ // The tiles take the clips' shape (Adam, 2026-10-22): wide clips only (a
+ // wide screen) go one to a row, their posters whole, not slivers cut to
+ // portrait tiles; every shape ("All shapes") is laid out in justified
+ // rows, each tile its clip's own proportions and each row as tall as it
+ // takes to fill the width; tall ones only (a phone held upright) keep
+ // the tall tiles three across.
+ // Pictures go in justified rows too, in their own shapes.
+ const shape = mediaMode === 'images' ? ' justified' : mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide';
+ return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
// a video tile shows a still at once: kept, given, or the server's
@@ -12013,18 +14744,28 @@ export function messagesFeature(ctx) {
const fromServer = mediaMode === 'videos' && poster && poster === serverThumb(it.url);
return h('button', {
class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k,
+ style: shape === ' justified' ? '--ar:' + itemRatio(it).toFixed(3) : undefined,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
+ onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined,
},
mediaMode === 'images' || poster
? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false',
loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async',
+ onLoad: (e) => arFromStill(e.currentTarget, it.url),
onError: (e) => {
if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn
+ // remembered, not only a class on the tile: the next
+ // repaint rewrites the class list, and the broken-picture
+ // glyph came back (a deleted nostr.media file in #artstr)
+ deadTiles.add(it.url);
const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken');
+ scheduleRepaint();
} })
// no still to show: the clip's first frame, fetched once the tile is near
: h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }),
- mediaMode === 'videos' && poster && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null,
+ // (drawn here whatever the tile shows, so a repaint keeps the one
+ // the frame-grab added)
+ mediaMode === 'videos' && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null,
mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null);
}));
}
@@ -12038,8 +14779,14 @@ export function messagesFeature(ctx) {
if (!e.isIntersecting) continue;
const v = e.target;
tileObs.unobserve(v);
- const tile = v.closest('.media-tile');
- if (tile) tile._skipMorph = true;
+ // the player (and whatever stands in for it) is left alone by
+ // repaints, which would strip its src; the tile around it keeps up
+ // with them (its shape when the grid's layout changes, its class).
+ // Freezing the whole tile kept a tile built for one-to-a-row out of
+ // the justified rows: no --ar, no width.
+ v._skipMorph = true;
+ const standIn = (el) => { el._skipMorph = true; return el; };
+ const badge = (el) => { if (!el.parentNode.querySelector('.media-tile-badge')) el.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); };
// A first frame costs the start of the file when the index leads or
// the server serves ranges — whatever the clip's size. Only an
// index-last file without ranges would have to come down whole for
@@ -12053,9 +14800,11 @@ export function messagesFeature(ctx) {
if (await grabFrame(url)) {
if (!v.isConnected) return;
const img = document.createElement('img');
- img.alt = ''; img.draggable = false; img.src = videoThumbs.get(url);
+ img.alt = ''; img.draggable = false;
+ img.addEventListener('load', () => arFromStill(img, url), { once: true });
+ img.src = videoThumbs.get(url);
v.replaceWith(img);
- if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
+ if (heavy && d.size) badge(img);
return;
}
if (!v.isConnected) return;
@@ -12067,7 +14816,7 @@ export function messagesFeature(ctx) {
const cap = setTimeout(() => {
if (v.readyState >= 2 || !v.isConnected) return;
try { v.removeAttribute('src'); v.load(); } catch {}
- v.replaceWith(h('span', { class: 'media-tile-cost' }, ''));
+ v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, '')));
}, 8000);
v.addEventListener('loadeddata', () => clearTimeout(cap), { once: true });
}
@@ -12075,11 +14824,23 @@ export function messagesFeature(ctx) {
// (a plain load: shown, not kept — a player's pixels can only be
// read from a CORS load, and those fail on a cached plain copy)
v.muted = true;
+ // a clip the player can't load either is gone as far as the grid
+ // goes: a deleted file whose 404 came without CORS headers
+ // (Primal's blossom) only looked like a quiet host to the probe,
+ // and its tile sat blank. Left out for a day, like a 404 the
+ // probe could read.
+ v.addEventListener('error', () => {
+ if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap
+ noteVideoInfo(url, { goneAt: Date.now() });
+ const b = v.closest('.media-tile');
+ if (b) b.classList.add('broken');
+ scheduleRepaint();
+ }, { once: true });
v.src = v.getAttribute('data-src');
- if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
+ if (heavy && d.size) badge(v);
return;
}
- v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''));
+ v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')));
});
}
}, { rootMargin: '400px 0px' });
@@ -12089,16 +14850,61 @@ export function messagesFeature(ctx) {
}
}
+ // Opening a clip from the grid: on Adam's phone a player made at the tap
+ // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting
+ // its sound ~0.3 — the clip's opening already in the cache saved nothing),
+ // and 1.8 s when the host's connection had to be made too. So the grid's
+ // clip hosts are connected to ahead (a preconnect: cheap), and the
+ // player starts loading when the finger comes down on the tile — the
+ // pager adopts that same element (pagerSlide), so the load overlaps the
+ // tap and the pager's opening. One the tap doesn't follow (a scroll) is
+ // let go.
+ const preconnected = new Set();
+ function preconnect(url) {
+ let origin; try { origin = new URL(url, location.href).origin; } catch { return; }
+ if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return;
+ preconnected.add(origin);
+ const l = document.createElement('link');
+ l.rel = 'preconnect'; l.href = origin;
+ document.head.append(l);
+ }
+ let early = null; // { url, v, timer }
+ function earlyPlayer(url) {
+ if (frugalData() || (early && early.url === url)) return;
+ dropEarly();
+ const v = document.createElement('video');
+ v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true;
+ v.setAttribute('src', playSrc(url));
+ v.dataset.early = '1'; // (the pager's adoption of it, for tests)
+ // on the page (out of sight) while it waits: a detached player isn't loaded everywhere
+ v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none';
+ document.body.append(v);
+ early = { url, v, timer: setTimeout(dropEarly, 2500) };
+ }
+ function dropEarly() {
+ if (!early) return;
+ clearTimeout(early.timer);
+ try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {}
+ early = null;
+ }
+ function takeEarly(url) {
+ if (!early || early.url !== url) return null;
+ const { v } = early;
+ clearTimeout(early.timer);
+ early = null;
+ return v;
+ }
+
// `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');
@@ -12117,6 +14923,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);
};
@@ -12170,10 +14985,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();
}
@@ -12196,8 +15012,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);
@@ -12207,11 +15025,44 @@ export function messagesFeature(ctx) {
function pagerSlide(it) {
const { ev, url } = it;
const video = pager.kind === 'videos';
- const media = video
+ // 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.
+ // (the clip a grid tile was pressed on: the player already loading since the finger came down)
+ const adopted = video && !frugalData() ? takeEarly(url) : null;
+ if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); }
+ const media = adopted || (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));
+ } }));
+ if (video) capUpscale(media);
+ 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,
@@ -12219,23 +15070,29 @@ 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 = withMoment(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(() => {
+ // a reply written there is about where the clip was
+ const moment = momentOf(ev);
leavePager(); openNoteThread(ev);
+ if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment };
}) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })),
h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => {
if (signinAsk()) { leavePager(); render(); return; }
// the first zap ever asks for an amount on a page of its own
const at = e.currentTarget.getBoundingClientRect();
+ const moment = momentOf(ev);
if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
- zapNote(ev.pubkey, ev, at);
+ zapNote(ev.pubkey, ev, at, moment);
recheckZap(ev.id);
setTimeout(() => pagerCounts(slide), 50);
}) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' })))));
@@ -12247,7 +15104,8 @@ export function messagesFeature(ctx) {
guardPicture(media, url);
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
- slide.append(seekBar(media));
+ media._momentUrl = url;
+ slide.append(seekBar(media, () => momentsOn(ev.id, url)));
}
return slide;
}
@@ -12256,14 +15114,75 @@ export function messagesFeature(ctx) {
// (the clip holds still meanwhile, and the time shows); the drag is the
// bar's alone, never the pager's swipe or its tap.
const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); };
- function seekBar(v) {
+ // A big screen blew a small clip up into blocks (Adam, 2026-10-22: "videos
+ // that are low res … so pixellated on my large 1920 monitor"). In a big
+ // window (a phone on its side is too short) a clip is drawn at most 1.5×
+ // its own pixels,
+ // smaller ones sitting in the middle on the pager's black. A phone fills
+ // its screen as before.
+ const MAX_UPSCALE = 1.5;
+ function capUpscale(v) {
+ const fit = () => {
+ const big = v.videoWidth && matchMedia('(min-width: 1000px) and (min-height: 600px)').matches;
+ const k = MAX_UPSCALE / (window.devicePixelRatio || 1);
+ v.style.maxWidth = big ? Math.round(v.videoWidth * k) + 'px' : '';
+ v.style.maxHeight = big ? Math.round(v.videoHeight * k) + 'px' : '';
+ };
+ v.addEventListener('loadedmetadata', fit);
+ if (v.readyState >= 1) fit();
+ }
+ // up for a few seconds, and on while a finger is on it
+ // (a mouse brings it up by moving, so it goes sooner once the mouse is
+ // still, and stays while the pointer rests on the bar itself)
+ function revealSeek(bar, ms = 3500) {
+ bar.classList.add('reveal');
+ clearTimeout(bar._hide);
+ bar._hide = setTimeout(() => { if (bar._drag || bar._hover) revealSeek(bar, ms); else bar.classList.remove('reveal'); }, ms);
+ }
+ // What was said, reacted or zapped at a moment of the clip sits over the
+ // bar at that moment (a tap goes there and says who and what), and floats
+ // up off it as the clip plays past.
+ function seekBar(v, moments = () => []) {
const fill = h('div', { class: 'mp-seek-fill' });
const time = h('span', { class: 'mp-seek-time' });
- const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time);
+ const marks = h('div', { class: 'mp-marks' });
+ const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time);
+ bar.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') bar._hover = true; });
+ bar.addEventListener('pointerleave', () => { bar._hover = false; });
+ let items = [], sig = '', checked = 0, last = 0;
+ const say = (x) => {
+ time.textContent = clock(x.moment.seconds) + ' ' + x.text;
+ bar.classList.add('say');
+ clearTimeout(bar._say);
+ bar._say = setTimeout(() => bar.classList.remove('say'), 3000);
+ revealSeek(bar);
+ };
+ const paintMarks = () => {
+ checked = performance.now();
+ items = moments();
+ const next = items.map((x) => x.id).join(',') + '@' + v.duration;
+ if (next === sig) return;
+ sig = next;
+ marks.replaceChildren(...items.map((x) => h('button', {
+ type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, v.duration) + '%',
+ onPointerdown: (e) => e.stopPropagation(),
+ onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); },
+ }, momentGlyph(x))));
+ };
+ v.addEventListener('mediaactivity', () => { checked = 0; });
+ // a moment the clip just played through floats up from its place
+ const pop = (x) => {
+ const slide = bar.closest('.mp-slide');
+ if (!slide || !pager || pager.cur !== slide) return;
+ const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x));
+ slide.append(el);
+ setTimeout(() => el.remove(), 1700);
+ };
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); };
const show = (f) => {
fill.style.width = (f * 100).toFixed(2) + '%';
- time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration);
+ if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment)
};
const to = (f) => { try { v.currentTime = f * v.duration; } catch {} };
bar.addEventListener('pointerdown', (e) => {
@@ -12272,6 +15191,7 @@ export function messagesFeature(ctx) {
try { bar.setPointerCapture(e.pointerId); } catch {}
bar._drag = { playing: !v.paused, id: e.pointerId };
bar.classList.add('drag');
+ bar.classList.remove('say');
v.pause();
const f = frac(e); show(f); to(f);
});
@@ -12286,6 +15206,7 @@ export function messagesFeature(ctx) {
if (!d || d.id !== e.pointerId) return;
bar._drag = null;
bar.classList.remove('drag');
+ if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag
if (e.type === 'pointerup') to(frac(e));
if (d.playing) v.play().catch(() => {});
};
@@ -12296,7 +15217,12 @@ export function messagesFeature(ctx) {
if (bar._drag) return;
const ok = v.duration > 0 && isFinite(v.duration);
bar.classList.toggle('ready', ok);
- if (ok) show(v.currentTime / v.duration);
+ if (!ok) return;
+ show(v.currentTime / v.duration);
+ if (performance.now() - checked > 1000) paintMarks();
+ const now = v.currentTime;
+ if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x);
+ last = now;
};
return bar;
}
@@ -12304,7 +15230,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
@@ -12318,22 +15250,75 @@ 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)
+ out.back = back || null;
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);
+ }
+ // A player's first play with sound waits on the phone's audio output to
+ // start: on Adam's Android ~250 ms of a still picture, every clip, warm
+ // or not (muted, 60 ms; a muted start then unmuted freezes the picture
+ // for those 250 ms instead). A player that has played with sound resumes
+ // in 20-80 ms, even many seconds later. So a warm player plays at a
+ // volume nobody hears until its picture moves (a frame or two in: the
+ // audio's start is the wait) and pauses there — not back to 0: a seek
+ // undoes it. Only while the pager has sound on, and the browser lets it.
+ // Having played, the player no longer stops reading at its first frame —
+ // it fetches the rest of the file — so only a clip known to be small
+ // enough to be worth that is primed (most are 3-25 MB).
+ const PRIME_MAX = 25e6;
+ function primeSound(s, v) {
+ if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return;
+ const d = videoInfo.get(v.getAttribute('data-src')) || {};
+ if (!(d.size > 0 && d.size <= PRIME_MAX)) return;
+ const go = () => {
+ if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return;
+ v._primed = true;
+ v.muted = false; v.volume = 0.001;
+ const done = () => {
+ clearTimeout(v._primeTimer);
+ if (pager && pager.cur !== s) { try { v.pause(); } catch {} }
+ v.volume = 1;
+ if (pager && pager.cur !== s) v.muted = true;
+ };
+ v._primeTimer = setTimeout(done, 2000);
+ const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); };
+ v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); });
+ };
+ if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true });
+ }
function pagerRelease(slide) {
const m = slide.querySelector('video.mp-media');
- if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} }
+ if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} }
}
// Flipping through clips is asking to watch them: whatever they cost, the
// one on screen plays at once (streaming the rest as it goes), and the
@@ -12356,11 +15341,30 @@ 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) {
+ // the next clip and the one before: a player of its own, warm — once
+ // its opening is in the cache, given the file to read up to its
+ // first frame (preload 'metadata': Chrome reads ~2 s of it and
+ // stops) and paused there, so the swipe onto it is only play and
+ // sound (TikTok's trick). The one just left keeps the player it
+ // played in (a swipe back to it took 0.7 s on a phone, built anew).
+ // The others ahead get their opening only.
+ const warm = k === 0 || s === ahead.back;
+ if (warm && v.getAttribute('src')) return; // warm already
pagerRelease(s);
- headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ if (warm) head.then(() => {
+ const now = pager && pager.cur && pagerAhead(pager.cur);
+ if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return;
+ v.preload = 'metadata';
+ v.muted = true;
+ pagerAttach(s);
+ primeSound(s, v);
+ });
return;
}
if (k === 0) {
@@ -12374,6 +15378,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.
@@ -12406,14 +15460,19 @@ export function messagesFeature(ctx) {
if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size;
return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity));
}
- const headStarted = new Set();
+ const headStarted = new Map(); // url -> its promise
+ const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read)
function headStart(url, share = 1) {
- if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return;
- headStarted.add(url);
- if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value);
+ if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve();
+ if (headStarted.has(url)) return headStarted.get(url);
+ if (isHls(url)) { // a stream's head start: its playlists and its first piece, the smaller rendition's
+ const task = hlsHead(url).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); });
+ headStarted.set(url, task);
+ return task;
+ }
const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share));
const t0 = performance.now();
- fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
+ const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => {
const reader = r.ok && r.body ? r.body.getReader() : null;
if (!reader) return;
let got = 0;
@@ -12425,7 +15484,20 @@ export function messagesFeature(ctx) {
}
const secs = (performance.now() - t0) / 1000;
if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; }
- }).catch(() => {});
+ }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); });
+ headStarted.set(url, task);
+ if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); }
+ return task;
+ }
+ async function hlsHead(url) {
+ const get = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(20_000) }); if (!r.ok) throw new Error('HTTP ' + r.status); return r; };
+ const uri = (text, base) => { const l = text.split('\n').map((x) => x.trim()).find((x) => x && !x.startsWith('#')); return l && new URL(l, base).href; };
+ const master = await get(url), text = await master.text();
+ // a master playlist's first rendition (vmedia lists the smaller first), or a media playlist itself
+ let list = text, at = master.url || url;
+ if (/#EXT-X-STREAM-INF/.test(text)) { at = uri(text, at); list = await (await get(at)).text(); }
+ const seg = uri(list, at);
+ if (seg) await (await get(seg)).arrayBuffer();
}
function playSlide(slide) {
const v = slide.querySelector('video.mp-media');
@@ -12440,11 +15512,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) || {};
@@ -12466,8 +15538,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);
@@ -12489,6 +15563,9 @@ export function messagesFeature(ctx) {
else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true });
}
v.muted = !pagerLoud;
+ v.volume = 1;
+ clearTimeout(v._primeTimer);
+ clockStart(slide, v);
v.play().catch((err) => {
// sound needs a tap the browser counted: play quietly, the button says so
if (err && err.name === 'NotAllowedError' && !v.muted) {
@@ -12498,6 +15575,37 @@ export function messagesFeature(ctx) {
}
});
}
+ // How long from landing on a clip to its picture moving, kept for the
+ // last hundred (coinosPagerStarts() in the console, and dlog), with what
+ // shape the player was in when the swipe landed (warm: it had its frame).
+ const pagerStarts = [];
+ function clockStart(slide, v) {
+ const t0 = slide._arriveAt;
+ slide._arriveAt = 0;
+ if (!t0) return;
+ const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState;
+ const primed = !!v._primed;
+ const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0;
+ const d = videoInfo.get(url) || {};
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ const done = () => {
+ const ms = Math.round(performance.now() - t0);
+ pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url });
+ if (pagerStarts.length > 100) pagerStarts.shift();
+ dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host);
+ };
+ const tick = () => {
+ if (!v.isConnected || performance.now() - t0 > 20000) return;
+ if (v.currentTime > 0 && !v.paused) { done(); return; }
+ if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
+ };
+ tick();
+ }
+ if (typeof window !== 'undefined') window.coinosPagerStarts = () => {
+ const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b);
+ const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null;
+ return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() };
+ };
function pagerSound(on) {
pagerLoud = on;
if (!pager) return;
@@ -12511,7 +15619,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';
@@ -12524,7 +15632,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();
};
@@ -12553,12 +15661,9 @@ export function messagesFeature(ctx) {
if (v) {
const groups = pagerGroups();
const from = here && here.closest('.mp-group');
- const index = groups.indexOf(from) + v;
- const to = groups[(index + groups.length) % groups.length];
- if (to) {
- if (index < 0 || index >= groups.length) pagerWrap(to);
- else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
- }
+ // the ends are ends: no going round to the other (Adam, 2026-10-18)
+ const to = groups[groups.indexOf(from) + v];
+ if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true));
return !!to;
}
if (hz) {
@@ -12577,6 +15682,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();
@@ -12591,21 +15705,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 i = (next % stops.length + stops.length) % stops.length;
- if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; }
+ // a flight stops at the end it reaches
+ const i = Math.min(stops.length - 1, Math.max(0, stops.indexOf(from) + dir * (n <= 2 || !pagerCanFly() ? 1 : 2)));
const to = stops[i];
+ if (to === from && !live) return;
fly = { to, n, at: now, dir };
if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
+ pagerFlyTo(to);
+ }
+ // 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;
@@ -12613,35 +15751,44 @@ export function messagesFeature(ctx) {
pager.el.classList.toggle('has-right', !!slide.nextElementSibling);
}
- // Crossing the boundary jumps directly to the other end, without flying
- // backwards through every loaded post or waiting for a network request.
- function pagerWrap(group) {
- if (!pager || !group) return;
- pager.scroller._onDone = null;
- pagerHold(pager.scroller);
- pager.scroller.style.scrollSnapType = '';
- pager.flying = false;
- const slide = rowSlide(group);
- if (slide) { pagerShow(slide); pagerArrive(slide); }
- }
+ // Counted: the posts in the carousel — the ones that can be reached. (All
+ // the posts found, the waiting ones too, was tried and undone: a count
+ // that can't be scrolled to confused — Adam, 2026-10-17.)
function pagerPosition() {
if (!pager) return;
const groups = pagerGroups();
const slide = pager.cur || pager.anchor;
- const index = groups.indexOf(slide && slide.closest('.mp-group'));
- pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length;
+ let index = groups.indexOf(slide && slide.closest('.mp-group'));
+ // in a flight, the post passing under the reader: the count runs with
+ // the slides rather than jumping on landing (Adam, 2026-10-17: "the
+ // counter should animate as we fly")
+ if (pager.flying && groups.length) {
+ const y = pager.scroller.scrollTop + pager.scroller.clientHeight / 2;
+ let lo = 0, hi = groups.length - 1; // the last post whose top is above the middle of the screen
+ while (lo < hi) { const m = (lo + hi + 1) >> 1; if (groups[m].offsetTop <= y) lo = m; else hi = m - 1; }
+ index = lo;
+ }
+ const text = (index < 0 ? 0 : index + 1) + ' / ' + groups.length, el = pager.el.querySelector('.mp-position');
+ if (el.textContent !== text) el.textContent = text;
+ // a video pager shows no count until it can fly: ten clips ready (Adam,
+ // 2026-10-17: "let's also not show the position/total counter until we have 10")
+ // (one with fewer posts than that, every one of them ready, counts them)
+ pager.el.classList.toggle('few', !pagerCanFly() && !!pager.scroller.querySelector(':scope > .mp-group.mp-wait:not(.mp-off)'));
}
function buildPager(want) {
const video = want.kind === 'videos';
const scroller = h('div', { class: 'mp-scroll' });
+ scroller.addEventListener('scroll', () => { if (pager && pager.flying) pagerPosition(); }, { passive: true });
const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' },
scroller,
h('div', { class: 'mp-position', role: 'status' }, '0 / 0'),
h('div', { class: 'mp-top' },
h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'),
- video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
- h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null),
+ h('div', { class: 'mp-top-r' },
+ clipsToggle('mp-fit', () => { pagerFit(); pagerWarmPics(); }),
+ video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } },
+ h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)),
// a mouse's way through (shown where there is a pointer to hover with,
// style.css): up and down the posts at the right edge, left and right
// along a post's pictures when it has more than one (has-left/right)
@@ -12696,13 +15843,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;
@@ -12722,18 +15888,33 @@ export function messagesFeature(ctx) {
const delta = d.axis === 'y' ? dy : dx;
d.far = Math.max(d.far || 0, Math.hypot(dx, dy));
if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta;
- d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp)
- 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;
+ // past either end, it stops at the end: going round to the other end
+ // was tried and undone (Adam, 2026-10-18: "let's not make the media
+ // pager circle around to the start anymore")
+ const next = from + step * posts;
+ if (y && posts > 1) {
+ const i = Math.min(stops.length - 1, Math.max(0, next));
+ if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; }
+ }
pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel);
}
el.addEventListener('touchend', (e) => {
@@ -12745,31 +15926,52 @@ 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, t.clientY); // (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, e.clientY));
+ // On a desktop the bar comes up while the mouse moves over a playing
+ // clip and fades once it's been still a moment (Adam, 2026-10-22).
+ el.addEventListener('pointermove', (e) => {
+ if (e.pointerType !== 'mouse' || !pager || !video) return;
+ const bar = pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar) revealSeek(bar, 2000);
});
// a tap shows (or hides) who posted it and what they said
- function pagerTap(target) {
+ function pagerTap(target, y) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it
+ // The seek bar is out of sight while a clip plays (Adam, 2026-10-19:
+ // only when someone goes for it, or pauses): a tap near the foot of
+ // the screen, where it sits, brings it up without pausing.
+ const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; }
if (pager && video) {
// A clip (from the feed or the media grid): a tap anywhere but the
// seek bar and the buttons pauses it and brings up who posted it,
@@ -12782,11 +15984,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;
@@ -12794,7 +15997,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;
@@ -12802,9 +16005,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;
@@ -12814,16 +16021,21 @@ 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();
+ slide._arriveAt = performance.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);
pagerLoadAhead();
+ pagerWarmPics();
};
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
@@ -12842,10 +16054,25 @@ export function messagesFeature(ctx) {
pager.anchor = first; // the slide meant to be on screen, from the first moment
pager.screenH = scroller.clientHeight;
pagerLoadAhead();
- // the clip on screen's seek bar, every frame (timeupdate comes a few times a second)
- if (video) {
+ // the clip on screen's seek bar, every frame (timeupdate comes a few
+ // times a second). While the pager is in motion — under a finger, in a
+ // glide or a flight, snapping back — no seek bar shows at all: the
+ // clip's slide is off its mark, and the bar rode along with it, a
+ // white line sliding up the screen (Adam, 2026-10-16: "i don't want to
+ // see the seek bar while i'm transitioning to the next video"). It
+ // fades back in once the clip is square on the screen. (The motion flag
+ // is a picture pager's too: its counter shows while it moves.)
+ {
const p = pager;
- const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); };
+ const frame = () => {
+ if (pager !== p) return;
+ const bar = p.cur?.querySelector('.mp-seek');
+ if (bar) bar._paint();
+ const r = p.cur && p.cur.getBoundingClientRect();
+ p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2));
+ if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); }
+ p.raf = requestAnimationFrame(frame);
+ };
p.raf = requestAnimationFrame(frame);
}
// turned on its side (or back), the slides change height: stay on this one
@@ -12861,6 +16088,8 @@ export function messagesFeature(ctx) {
if (!pager) return;
pager.screenH = pager.scroller.clientHeight;
if (pager.anchor) pagerShow(pager.anchor);
+ for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel();
+ pagerFit();
};
window.addEventListener('resize', pager.onResize);
// new posts land in the feed while the pager is open; counts move
@@ -12890,7 +16119,7 @@ export function messagesFeature(ctx) {
async function pagerLoadAhead() {
const p = pager;
if (!p) return;
- const groups = pagerGroups();
+ const groups = pagerGroups(true);
const slide = p.cur || p.anchor;
p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group')));
if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return;
@@ -12899,7 +16128,8 @@ export function messagesFeature(ctx) {
try {
// Bound each run; the timer resumes if sparse media needs more pages.
for (let pages = 0; pages < 8 && pager === p; pages++) {
- if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
+ // (the posts found, waiting ones too: ready ones come only as fast as they warm)
+ if (pagerFound().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break;
const src = pagerLoadSource(p);
if (src.done()) break;
const before = src.progress();
@@ -12922,7 +16152,7 @@ export function messagesFeature(ctx) {
const c = feedNow();
pager.groups ||= new Map();
const src = pagerSource();
- for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) {
+ for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind, true))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
@@ -12931,16 +16161,119 @@ 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');
+ // the grid's pager goes by the grid's rule: a clip of a shape not yet
+ // known stays out until it's measured (fitClip measures it); a page's
+ // pager shows one until it's known not to fit
+ const strict = pager.kind === 'videos' && !pagerSource();
+ const ok = (u) => (strict ? fitClip(u) : clipFits(u) !== false);
+ 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 || ok(m.getAttribute('data-full') || m.getAttribute('data-src')); });
+ 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() {
@@ -12960,6 +16293,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;
@@ -12982,11 +16316,11 @@ export function messagesFeature(ctx) {
const c = feedNow();
c.opened = true;
const def = feedDef();
- const visitor = isVisitor();
+ const visitor = visitorView();
queueMicrotask(() => {
syncMediaPager();
- if (mediaMode === 'all') watchSeen();
- if (mediaMode !== 'all') watchMediaTiles();
+ if (listView()) watchSeen();
+ else watchMediaTiles();
});
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
@@ -12999,7 +16333,10 @@ export function messagesFeature(ctx) {
// their settled presentation once the warm-up lands. The spinner is
// for a feed with nothing cached at all.
const curatedFeed = !!(feedDef(c.id) || {}).curated;
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev));
+ // (a reply whose post every lookup came back without isn't shown: a
+ // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
+ const repliesHere = feedReplies(c);
+ const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -13013,7 +16350,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 —
@@ -13058,7 +16395,7 @@ export function messagesFeature(ctx) {
),
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
- mediaModes(),
+ (refreshLive(), mediaModes()),
// floating bottom right, wherever the reader is in the feed: the
// composer opens at the top and takes the focus
visitor || ui.profCompose != null ? null : h('button', {
@@ -13083,25 +16420,27 @@ 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')))
+ : mediaMode === 'live' ? liveGrid(def)
: (c.booting || c.status === 'loading') && !visible.length
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' }))
: !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)
+ mediaMode === 'live' ? null
+ : 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,
@@ -13162,7 +16501,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,
@@ -13203,7 +16542,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();
@@ -14087,6 +17426,13 @@ export function messagesFeature(ctx) {
render();
}
+ const chatBubbleClick = (open) => (e) => {
+ if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
+ const sel = window.getSelection && window.getSelection();
+ if (sel && String(sel).length) return;
+ open();
+ };
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -14135,13 +17481,10 @@ export function messagesFeature(ctx) {
// ends in a click too, and must not pop the sheet over the copy.
// A custom emoji is an <img> too, but tapping it should open the
// sheet like tapping any other word.
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
replyQuote(room, chId, m),
...noteBody(text, 0, em),
@@ -14532,13 +17875,10 @@ export function messagesFeature(ctx) {
h('span', { class: 'chat-time' }, timeLabel(tms))),
h('div', {
class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''),
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
},
src ? h('div', { class: 'chat-quote' },
h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)),
@@ -14656,7 +17996,8 @@ export function messagesFeature(ctx) {
const peer = ui.msgPeer;
if (!peer) { ui.msgView = 'home'; return homeView(); }
const thread = threads.get(peer) || new Map();
- const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at);
+ if (!isMe(peer)) setTimeout(() => callWarm(peer), 1500); // a call may be next
+ const msgs = foldCallLogs([...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at));
// a first message may go by White Noise: have their KeyPackage looked up before Send
wnKnow(peer);
const choice = routeChoice(peer);
@@ -14704,12 +18045,16 @@ export function messagesFeature(ctx) {
h('div', { class: 'col clickable', style: 'gap:2px;min-width:0', onClick: () => openProfile(peer) },
h('div', { class: 'chat-title' }, displayName(peer)),
h('div', { class: 'muted small' }, t('msgDmEncrypted'))),
- // Only where there is a choice: this person can be reached two ways.
- // The pill says which way the next message goes; tapping it pins one.
- choice ? h('button', {
- class: 'btn-sm route-pill', style: 'margin-left:auto;flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true',
- onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); },
- }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null),
+ h('div', { class: 'row gap6', style: 'margin-left:auto;flex-shrink:0;align-items:center' },
+ // a call: voice or video, to this one person
+ isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'voice', title: t('callVoice'), 'aria-label': t('callVoice'), onClick: () => startCall(peer, 'voice') }, callIcon('phone')),
+ isMe(peer) ? null : h('button', { class: 'iconbtn call-start', 'data-call': 'video', title: t('callVideo'), 'aria-label': t('callVideo'), onClick: () => startCall(peer, 'video') }, callIcon('video')),
+ // Only where there is a choice: this person can be reached two ways.
+ // The pill says which way the next message goes; tapping it pins one.
+ choice ? h('button', {
+ class: 'btn-sm route-pill', style: 'flex-shrink:0', title: t('msgRouteTitle'), 'aria-haspopup': 'true',
+ onClick: () => { ui.msgRoutePick = !ui.msgRoutePick; render(); },
+ }, t(choice.now === 'wn' ? 'msgRouteWn' : 'msgRouteDm'), ' ▾') : null)),
h('div', {
class: 'chat-log',
onScroll: (e) => {
@@ -14718,19 +18063,16 @@ export function messagesFeature(ctx) {
},
},
...(msgs.length
- ? msgs.map((m) =>
+ ? msgs.map((m) => m.rumor.kind === CALL_LOG ? callLogRow(m, peer) :
h('div', { class: 'chat-row dm' + (m.mine ? ' mine' : '') },
h('div', { class: 'chat-body' },
h('div', { class: 'chat-line' }, h('div', {
class: 'chat-bubble clickable' + (m.mine ? ' me' : '') + (emojiJumbo(m.rumor.content, emojiTagMap(m.rumor.tags)) ? ' jumbo' : ''),
// same tap-for-actions as community bubbles
- onClick: (e) => {
- if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return;
- const sel = window.getSelection && window.getSelection();
- if (sel && String(sel).length) return;
+ onClick: chatBubbleClick(() => {
ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id;
render();
- },
+ }),
}, dmQuote(m), ...(m.rumor.kind === 15 ? [] : noteBody(m.rumor.content, 0, emojiTagMap(m.rumor.tags))), ...attachmentNodes(m.rumor, m.wn), dmChips(m)),
m.mine ? null : msgZapBtn(m.rumor.pubkey, m.rumor.id)),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
@@ -14753,6 +18095,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
@@ -14812,6 +18176,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);
}
@@ -14857,7 +18222,9 @@ export function messagesFeature(ctx) {
if (ZAP_KINDS.includes(ev.kind)) {
const actor = zapperOf(ev), sats = receiptSats(ev);
if (!actor || my.includes(actor) || !sats) return null;
- return { id: ev.id, what: 'zap', actor, target: lastE, hint, sats, text: zapText(ev), ts: ev.created_at };
+ // a zap on a stream names it by address (a 30311), not by an e tag
+ const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null;
+ return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at };
}
if (my.includes(ev.pubkey)) return null;
if (ev.kind === 7) {
@@ -14924,9 +18291,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';
@@ -15044,6 +18413,10 @@ export function messagesFeature(ctx) {
if (target) openNoteThread(target);
// a note we couldn't find is asked for again on tap, hints and all
else if (x.target) openNoteRef({ id: x.target, relays: x.hint || [] }).catch(() => {});
+ // a zap on a stream opens the stream; one on your profile (no post
+ // at all) opens the zapper's — the tap used to do nothing
+ else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); }
+ else { stopNotifWatch(); openProfile(x.actor); }
}
};
return h('div', {
@@ -15138,10 +18511,14 @@ export function messagesFeature(ctx) {
resumed(awayMs) {
// a blink between apps didn't kill anything
if (awayMs && awayMs < 5_000) return;
- resubscribeStreams(); // DMs and rooms, whether or not chat is on screen
- if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return true;
- if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {});
- else refreshFeed({ force: true }).catch(() => {});
+ // the sockets first: after a freeze they read open and are dead, and
+ // a refresh sent on them finds nothing (wakeRelays)
+ wakeRelays().catch(() => {}).then(() => {
+ resubscribeStreams(); // DMs and rooms, whether or not chat is on screen
+ if (!ui.chatOpen || !['feed', 'notifs'].includes(ui.msgView)) return;
+ if (ui.msgView === 'notifs') refreshNotifs(true).catch(() => {});
+ else refreshFeed({ force: true }).catch(() => {});
+ });
return true;
},
// A zap flow reporting how its payment went, so the chip that appeared
@@ -15198,12 +18575,14 @@ export function messagesFeature(ctx) {
bottomNav: appBottomNav,
notifySettingsCards() { return [notifyCard()]; },
screenView() {
+ liveShown = false;
+ queueMicrotask(liveAfterRender);
// A profile deep link mid-resolution holds the frame over EVERY screen
// — an auto-restored wallet would otherwise flash its home page for the
// beat the registrar lookup takes. The shell shows the name straight
// off the URL; no spinner, the empty beat reads calmer.
// Same for a note deep link while its thread is being found.
- if (ui.pubNotePending && !ui.noteThread) return h('div', { class: 'col', style: 'gap:16px' },
+ if (ui.pubNotePending && !ui.noteThread && !ui.liveStream) return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card row gap6', style: 'justify-content:center;align-items:center;padding:16px' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading'))));
@@ -15219,6 +18598,8 @@ export function messagesFeature(ctx) {
// threads reachable from it, are public nostr content — shown
// without an account. Back falls through to the app's own screens.
if (ui.pubProf) {
+ if (ui.zapSetup && ui.liveStream) return zapSetupScreen();
+ if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
@@ -15228,6 +18609,8 @@ export function messagesFeature(ctx) {
return null;
}
if (ui.zapSetup) return zapSetupScreen();
+ if (ui.goLive) return goLiveScreen();
+ if (ui.liveStream) return liveScreen();
if (ui.profOverThread && ui.profilePk) return profileScreen();
if (ui.noteThread) return threadScreen();
if (ui.profilePk) return profileScreen();
@@ -15241,7 +18624,7 @@ export function messagesFeature(ctx) {
userSearchAvailable() { return true; },
// the desktop header's search field: typing there IS the search page's query
userSearchQuery(q) {
- if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
+ if (!ui.userSearch) { if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.userSearch = { q: '', rows: null }; warmSearch(); }
ui.userSearch.q = q; userSearcher.update(q); render();
return true;
},
@@ -15322,13 +18705,13 @@ export function messagesFeature(ctx) {
// A payment-address rename released the old name: repoint the kind 0's
// lud16 and nip05 — but only where they pointed at the released address
// (or were empty). Deliberately different values are not ours to touch.
- addressRenamed(oldAddr, newAddr) {
- const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
- return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
- onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
- fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
- }).catch(() => {});
- },
+ addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); },
+ // older copies of our kind 0 on history-keeping relays, and filling the
+ // open editor's empty fields from one (tests)
+ profileHistory() { return findProfileHistory(); },
+ fillProfileFrom(e) { return fillProfileFrom(e); },
+ // the profile editor's Save, without the editor (tests)
+ async saveProfileDraft(e) { const id = await identity(); return withKind0Lock(() => saveProfileNow(id, { name: '', about: '', picture: '', banner: '', ...e })); },
// opens the profile, unless the caller has a step to take first (the
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {
@@ -15340,6 +18723,21 @@ export function messagesFeature(ctx) {
}, avatar(pk, 'chat-avatar ' + (big ? 'send-avatar' : 'mini'), false),
h('span', { class: big ? '' : 'small' }, displayName(pk)));
},
+ // a gift link names its sender: sign its template as us (gifts.js)
+ async signGiftFrom(tmpl) {
+ const id = await identity();
+ if (!id) return null;
+ return id.signer instanceof Uint8Array ? finalizeEvent(tmpl, id.signer) : id.signer.signEvent(tmpl);
+ },
+ // A claimed gift's sender: follow them, once any follow publish in
+ // flight (a fresh wallet's starter follows) has landed — followMany
+ // drops a call that arrives mid-publish.
+ async followGiftSender(pk) {
+ for (let i = 0; followsPub && i < 60; i++) await new Promise((r) => setTimeout(r, 500));
+ if (!(await identity())) return false;
+ await followMany([pk]).catch(() => {});
+ return true;
+ },
// A sign-in (Google, passkey, key) that found no wallet on its identity:
// the same start, but only the follows of an identity that follows
// nobody yet — an established nostr user keeps their own list.
@@ -15350,7 +18748,7 @@ export function messagesFeature(ctx) {
visitorSignIn() { toFrontDoor(); return true; },
visitorSearch() {
rememberReturn();
- ui.profilePk = null; ui.noteThread = null; ui.profOverThread = false;
+ ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false;
ui.userSearch = { q: '', rows: null };
warmSearch();
render();
@@ -15363,11 +18761,13 @@ export function messagesFeature(ctx) {
return true;
},
navPath(snap) {
+ if (snap && snap.liveStream && snap.liveStream.a) { const n = packNaddr(snap.liveStream.a); if (n) return '/' + n; }
if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null;
const def = feedDef(snap.feedId || curFeedId);
return def && adhocFeeds.has(def.id) ? feedPath(def) : null;
},
avatarNode(pk, cls) { return avatar(pk, cls || 'chat-avatar mini', false); },
+ callOverlay() { return callOverlay(); },
init() {
const session = ++feedWarmSession;
// this wallet's own feed (the feature outlives a sign-out): a feed
@@ -15399,8 +18799,15 @@ export function messagesFeature(ctx) {
try {
navigator.serviceWorker?.addEventListener('message', (ev) => {
if (ev.data && ev.data.type === 'open' && ev.data.view === 'notifs') openNotifs();
+ // a call the worker was woken for: rung here too (an app in the
+ // background may have missed it live), or picked up from the tap
+ if (ev.data && ev.data.type === 'call-offer' && ev.data.offer) callsNow().handle(ev.data.offer, { maxAge: RING_S }).catch(() => {});
+ if (ev.data && ev.data.type === 'open' && ev.data.view === 'call') pendingCall();
});
} catch {}
+ // a tapped "is calling" opened the app, or brought it back: the offer the worker kept
+ setTimeout(pendingCall, 0);
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') pendingCall(); });
if (urlInvite && !pendingLink) {
loadLinkInvite(urlInvite);
setTimeout(() => { ui.chatOpen = true; ui.msgView = 'home'; render(); }, 0);
@@ -15457,6 +18864,7 @@ export function messagesFeature(ctx) {
clearTimeout(drainTimer);
pendingWraps.clear();
dmStarted = false;
+ if (calls) { try { calls.stop(); } catch {} calls = null; callInbox.clear(); iceCache = null; warmed.clear(); callRing(); }
listsSynced = false;
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
@@ -15474,11 +18882,11 @@ export function messagesFeature(ctx) {
stCache = null; stAt = 0; // the next account's state, not this one's
if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
profBlob = null;
- reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
+ timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
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/names.js b/src/features/names.js
index fe5ab0a..d57bb19 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -455,29 +455,35 @@ export function namesFeature(ctx) {
// said as it's typed, not after a round trip (the registrar is the judge:
// a name claimed before the rule can still be retaken by its owner)
nameRuleHint(ui.nameClaim) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
- h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => {
- const name = (ui.nameClaim || '').toLowerCase().trim();
- if (!name) return;
- ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render();
- try {
- // Sign with the login identity when one can be (re)attached: the
- // registrar lets a migrated coinos user's own identity take their
- // name back, which the wallet key alone could never prove.
- const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
- await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
- // the wizard's Spending step tells a name claimed here from one
- // the registrar handed back (an account set up before)
- if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name;
- ui.nameClaim = '';
- ui.nameEditOpen = false;
- toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` }));
- } catch (e) {
- ui.nameClaimError = e.message;
- if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null;
- }
- ui.busy = false; render();
- } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')),
- ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io'
+ h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: () => claimTyped(ui.nameClaim) }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')),
+ migrateLink());
+ }
+ async function claimTyped(typed) {
+ const name = (typed || '').toLowerCase().trim();
+ if (!name) return;
+ ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render();
+ try {
+ // Sign with the login identity when one can be (re)attached: the
+ // registrar lets a migrated coinos user's own identity take their
+ // name back, which the wallet key alone could never prove.
+ const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
+ await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
+ // the wizard's Spending step tells a name claimed here from one
+ // the registrar handed back (an account set up before)
+ if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name;
+ ui.nameClaim = '';
+ ui.nameEdit = null;
+ ui.nameEditOpen = false;
+ toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` }));
+ } catch (e) {
+ ui.nameClaimError = e.message;
+ if ((ui.nameEdit ?? ui.nameClaim) === name) ui.nameClaimMigration = e.migrationName || null;
+ }
+ ui.busy = false; render();
+ }
+ // "I have a coinos account": a name the registrar holds for a legacy user
+ function migrateLink() {
+ return ui.nameClaimMigration && ui.nameClaimMigration === (ui.nameEdit ?? ui.nameClaim) && DOMAIN() === 'coinos.io'
? h('button', {
type: 'button', class: 'linklike small',
onClick: () => {
@@ -490,7 +496,48 @@ export function namesFeature(ctx) {
location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`;
}
},
- }, t('onbHaveCoinos')) : null);
+ }, t('onbHaveCoinos')) : null;
+ }
+
+ // Settings: the address and changing it are one card — the name sits in
+ // its own field; typing a different one offers the change (and says the
+ // old address stops working), otherwise Copy and Release.
+ function addressCard(st) {
+ const domain = st.domain || DOMAIN();
+ const addr = `${st.name}@${domain}`;
+ const typed = ui.nameEdit ?? st.name;
+ const changed = !!typed && typed !== st.name;
+ return h('div', { class: 'card col', 'data-key': 'names' },
+ h('h3', {}, t('namesTitle')),
+ ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null,
+ h('div', { class: 'row gap6 name-field' },
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: t('namesPlaceholder'), style: 'flex:1',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('namesCustom'),
+ value: typed,
+ onInput: (e) => {
+ const was = changed;
+ ui.nameEdit = e.target.value.toLowerCase().trim();
+ ui.nameClaimError = null;
+ if (ui.nameClaimMigration) ui.nameClaimMigration = null;
+ if ((!!ui.nameEdit && ui.nameEdit !== st.name) !== was || nameRuleHint(ui.nameEdit)) render();
+ },
+ }),
+ h('span', { class: 'muted', style: 'align-self:center' }, '@' + domain)),
+ changed && nameRuleHint(typed) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
+ changed
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost btn-sm', disabled: ui.busy, onClick: () => { ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); } }, t('cancel')),
+ h('button', { class: 'btn-primary btn-sm grow', disabled: ui.busy, onClick: () => claimTyped(typed) },
+ ui.busy ? h('span', { class: 'spinner sm' }) : t('namesChangeTo', { addr: typed }))),
+ migrateLink())
+ : h('div', { class: 'row gap6' },
+ copyBtn(addr, t('namesCopy')),
+ h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
+ await release(); toast(t('namesReleased')); render();
+ } }, t('namesRelease'))));
}
// Settings: one card per concern, all laid out (no collapsed sections —
@@ -519,14 +566,7 @@ export function namesFeature(ctx) {
h('div', { class: 'addr-box break', style: 'font-size:11px' },
`${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${domain}.`)),
card('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow'), ownNodeSection()),
- card('custom', t('namesCustom'), t('namesCustomHow'), claimForm(false)),
- card('names', t('namesTitle'), null,
- h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr),
- h('div', { class: 'row gap6' },
- copyBtn(addr, t('namesCopy')),
- h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
- await release(); toast(t('namesReleased')); render();
- } }, t('namesRelease')))),
+ addressCard(st),
].filter(Boolean);
}
// The terminal's cards live on the Point of sale settings page now — no
@@ -694,14 +734,25 @@ export function namesFeature(ctx) {
try {
const a = await hook('arkMakeInvoice', sat, (inv.memo || '').trim().slice(0, 100));
inv.invoice = a.invoice;
+ inv.expiresAt = a.expiresAt || 0;
+ // the QR gives way to "expired" when the hour is up
+ if (inv.expiresAt) setTimeout(() => { if (ui.namesInv === inv) render(); }, Math.max(0, inv.expiresAt - Date.now()) + 500);
} catch (e) { inv.error = e.message; }
inv.busy = false; render();
}
function invoicePane() {
const inv = ui.namesInv || (ui.namesInv = {});
+ // The server finishes a Lightning receive only while this wallet is
+ // open to take it: paid with coinos shut, the money goes back to the
+ // payer. The invoice lasts an hour, and the screen says both.
+ if (inv.invoice && inv.expiresAt && Date.now() >= inv.expiresAt)
+ return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' },
+ h('div', { class: 'small muted', style: 'text-align:center' }, t('namesInvExpired')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.namesInv = {}; render(); } }, t('namesInvNew')));
if (inv.invoice)
return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' },
+ h('div', { class: 'notice small names-inv-open', style: 'width:100%;text-align:center' }, t('namesInvKeepOpen')),
h('div', { html: qrSvg(inv.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, inv.invoice),
h('div', { class: 'row gap6' },
@@ -873,7 +924,7 @@ export function namesFeature(ctx) {
checked = false; lastError = null;
// The claim field is a draft for THIS account — a name typed before a
// logout used to greet the next account that opened on this device.
- ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null;
+ ui.nameClaim = null; ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null;
refresh();
},
// The pencil by the address opens this: choosing a name gets a page of
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index f7f9e16..83144cd 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -16,7 +16,7 @@ import { qrSvg } from '../qr.js';
import {
extensionSigner, bunkerSigner, keySigner, parseNostrSecret,
walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker,
- amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, onAmberChange } from '../nostr-login.js';
+ amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, amberOpen, onAmberChange } from '../nostr-login.js';
// The nostr ostrich, in the brand's monochrome.
const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>';
@@ -69,7 +69,7 @@ export function nostrLoginFeature(ctx) {
// decrypt that" is a healthy connection doing its job — the DM inbox probes
// it with wraps that turn out to be someone else's, and treating each
// refusal as a dead signer disconnected Amethyst users over and over.
- const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|socket|relay|network/i;
+ const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|not open|socket|relay|network/i;
const selfHealing = (signer) => {
if (!signer || signer.kind !== 'bunker') return signer;
const guard = (fn) => async (...args) => {
@@ -408,7 +408,13 @@ export function nostrLoginFeature(ctx) {
if (!p) return null;
const e = ui.amberPaste || (ui.amberPaste = { text: '' });
return h('div', { class: 'card col amber-wait', style: 'gap:8px' },
- h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
+ // a request waiting for a tap (see amberOpen): the Open button is the
+ // way forward, not the spinner
+ p.needsTap
+ ? h('div', { class: 'col', style: 'gap:6px' },
+ h('div', { class: 'small' }, t('nlAmberTap')),
+ h('button', { class: 'btn-primary btn-block amber-open', type: 'button', onClick: () => amberOpen() }, t('nlAmberOpen')))
+ : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
h('div', { class: 'small muted' }, t('nlAmberPasteHint')),
h('div', { class: 'row gap6' },
h('input', { type: 'text', class: 'grow amber-paste', placeholder: t('nlAmberPastePh'), value: e.text, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
@@ -605,12 +611,18 @@ export function nostrLoginFeature(ctx) {
// time, and a pause after a failure: identity() asks on every send, and a
// signer that isn't answering shouldn't mean a new relay connection per
// keystroke.
- async function resumeLogin() {
+ const tabHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden';
+ async function resumeLogin({ force = false } = {}) {
if (liveSigner()) return live;
const st = load();
if (!st.pubkey) return null;
if (resuming) return resuming;
- if (Date.now() - resumeFailedAt < 20_000) return null;
+ if (!force && Date.now() - resumeFailedAt < 20_000) return null;
+ // A phone freezes a hidden tab: a remote signer asked from there can't be
+ // heard, and that silence isn't the session failing — don't count it.
+ let wasHidden = tabHidden();
+ const onVis = () => { if (tabHidden()) wasHidden = true; };
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
resuming = (async () => {
// a stored passkey-derived key reattaches instantly — no network, no
// dialog; see sessionOf for why this key (alone among keys) is kept
@@ -639,10 +651,13 @@ export function nostrLoginFeature(ctx) {
})();
try {
const s = await resuming;
- if (!s) resumeFailedAt = Date.now();
- else announceLive();
+ if (!s && !wasHidden) resumeFailedAt = Date.now();
+ else if (s) announceLive();
return s;
- } finally { resuming = null; }
+ } finally {
+ resuming = null;
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
+ }
}
// Which door this account came through, for the reconnect surfaces. State
@@ -702,8 +717,31 @@ export function nostrLoginFeature(ctx) {
// reattach silently). Same guarded buttons as the settings card — an
// extension, the signer-app deeplink/QR, or a pasted key — and the run
// refuses a different account.
+ // While the reconnect screen is up, keep trying the stored session: the
+ // remote signer authorised this client key long ago and nearly always still
+ // answers — it just wasn't heard the moment the screen opened (tab frozen,
+ // relay socket cut, the signer app asleep until opened). One try and then
+ // nothing meant a brand-new connection every time.
+ let retryT = 0;
+ const retryStored = () => {
+ clearTimeout(retryT); retryT = 0;
+ if (ui.nostrReconnect !== true || liveSigner()) return;
+ const st = load();
+ if (!st.session || st.session.amber) return;
+ if (!tabHidden()) {
+ resumeLogin({ force: true }).then((sg) => {
+ if (sg && ui.nostrReconnect === true) { stopNostrConnect(); ui.nostrReconnect = null; ui.nostrConnectOpen = false; render(); }
+ }).catch(() => {});
+ }
+ retryT = setTimeout(retryStored, 10_000);
+ };
+ if (typeof document !== 'undefined') {
+ document.addEventListener('visibilitychange', () => { if (!tabHidden() && ui.nostrReconnect === true) retryStored(); });
+ }
+
function reconnectScreen() {
const st = load();
+ if (ui.nostrReconnect === true && !retryT && st.session && !st.session.amber) retryT = setTimeout(retryStored, 0);
// 'upgrade': Amber by intent is live but can't read messages — offer
// only its NIP-46 door (nostrconnect), same account enforced
const upgrade = ui.nostrReconnect === 'upgrade';
@@ -750,7 +788,7 @@ export function nostrLoginFeature(ctx) {
if (!st.pubkey || liveSigner()) return false;
ui.nostrReconnect = true;
ui.nostrLoginError = '';
- this.nostrLoginResume().then((sg) => {
+ resumeLogin({ force: true }).then((sg) => {
if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); }
}).catch(() => {});
render();
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 9cf8d0b..a2730f5 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -30,17 +30,23 @@ import { t } from '../i18n.js';
import { dlog } from '../debug.js';
import { qrSvg } from '../qr.js';
import { getNetwork } from '../api.js';
-import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
+import { loadBg, saveBg, clearBg, readBgLog } 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.
@@ -50,7 +56,7 @@ const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (code, message) => ({ error: { code, message } });
export function nwcFeature(ctx) {
- const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx;
+ const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, copy, toast } = ctx;
// Relay transport, injectable so the protocol can be driven in tests
// without a live relay.
const {
@@ -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,17 +472,27 @@ 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 {
res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat });
} catch (e) {
- // The ASP already holds a payment for this invoice: another device
- // of this wallet took the same request first. Its outcome is ours —
- // wait for it rather than report a failure over a zap that is
- // paying. The payer records the spend; this side only relays.
- if (!/already in progress/i.test(e.message || '')) throw e;
+ // A payment for this invoice already exists: ours, begun on an
+ // earlier request (the worker started it while closed, say), or
+ // another device's — the ASP refuses a second HTLC for one invoice.
+ // Its outcome is the answer, never an error whose text says "paid":
+ // a client once read "already paid or is being paid" as paid, hours
+ // before the money moved. The payer records the spend; this side
+ // only relays.
+ if (!/already in progress|already paid/i.test(e.message || '')) throw e;
+ const mine = await hook('arkLnLookup', { paymentHash: dec.paymentHash }).catch(() => null);
+ if (mine && mine.type === 'outgoing' && mine.state === 'settled' && mine.preimage) {
+ return { sibling: true, result: { preimage: mine.preimage, fees_paid: 0 } };
+ }
+ if (mine && mine.type === 'outgoing' && mine.state === 'pending') {
+ return errRes('OTHER', 'a payment for this invoice is still in progress; ask again in a moment');
+ }
const out = await hook('arkLnOutcome', dec.paymentHash, 60000);
if (!out || out.status !== 'success') throw e;
return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } };
@@ -624,7 +672,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 +691,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 +730,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 +763,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.
@@ -786,18 +832,35 @@ export function nwcFeature(ctx) {
// wrong keys — background wake-ups silently die the first time a
// connection is recreated. Debounced; the server replaces by endpoint.
let regTimer = null;
+ // The browser's subscription, made again when it's gone: iOS drops it after
+ // three pushes that showed no notification (and a key rotation strands an
+ // old one), and nothing else re-arms a wallet whose `background` is still on.
+ async function currentSub() {
+ const reg = await navigator.serviceWorker.ready;
+ const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json();
+ const want = b64ToBytes(publicKey);
+ let sub = await reg.pushManager.getSubscription();
+ if (sub) {
+ let have = [];
+ try { have = new Uint8Array(sub.options.applicationServerKey || []); } catch {}
+ if (have.length === want.length && have.every((b, i) => b === want[i])) return sub.toJSON();
+ try { await sub.unsubscribe(); } catch {}
+ }
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return null;
+ sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: want });
+ dlog('nwc: push subscription was gone, made a new one');
+ return sub.toJSON();
+ }
function refreshRegistration() {
if (!load().background) return;
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
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 }
- : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON());
+ const sub = up2 ? { endpoint: up2, unifiedpush: true } : await currentSub();
if (!sub) return;
await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
@@ -835,7 +898,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 +951,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
@@ -914,13 +977,17 @@ export function nwcFeature(ctx) {
if (typeof window !== 'undefined') {
window.addEventListener('online', () => listen());
document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible' && conns().length) listen();
+ if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); }
});
}
}
// ---- 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 +1010,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 +1060,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 +1079,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')));
}
@@ -1031,7 +1103,19 @@ export function nwcFeature(ctx) {
h('button', { class: 'btn-sm', onClick: () => {
const s = load(); s.paySentNotify = !on; save(s); writeBg(); render();
} }, on ? t('nwcOn') : t('nwcOff'))),
- h('div', { class: 'small faint' }, t('notifPaySentHint')))];
+ h('div', { class: 'small faint' }, t('notifPaySentHint'))),
+ // the worker's diary, for the report that says where a closed wallet
+ // got to (a phone stops it without a trace)
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row between' },
+ h('span', { class: 'small' }, t('nwcWorkerLog')),
+ h('button', { class: 'btn-sm', onClick: async () => {
+ const lines = await readBgLog();
+ if (!lines.length) { toast(t('nwcWorkerLogEmpty')); return; }
+ await copy(lines.join('\n'));
+ toast(t('copied'));
+ } }, t('copy'))),
+ h('div', { class: 'small faint' }, t('nwcWorkerLogHint')))];
},
};
}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d327f9d..76296cf 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';
@@ -130,11 +132,17 @@ export function zapsFeature(ctx) {
if (profile && profile.name) z.name = profile.name;
const lnTarget = zapTargetFromProfile(profile, target.pk);
if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; }
- z.target = target = { ...lnTarget, eventId: target.eventId || null }; // keep the note being zapped
+ z.target = target = { ...lnTarget, eventId: target.eventId || null, moment: target.moment }; // keep the note being zapped
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
@@ -188,6 +196,16 @@ export function zapsFeature(ctx) {
render();
}
+ // A zap request is signed as the person zapping: the login identity when
+ // there is one (the face their chat line and the streamer's overlay
+ // show), else the wallet's own key. It used to be the wallet key always,
+ // and a signed-in person's zaps showed under a stranger's npub.
+ async function zapSigner() {
+ const id = hook('nostrLoginIdentity');
+ const signer = id && (id.signer || await Promise.resolve(hook('nostrLoginResume')).catch(() => null));
+ return signer ? (e) => signer.signEvent(e) : (e) => wallet.nostrSign(e);
+ }
+
// One-tap zap at the user's default amount: resolve, invoice, pay through
// the ark seam (its quote + retries included), and report by toast — the
// whole flow without a single screen.
@@ -195,7 +213,7 @@ export function zapsFeature(ctx) {
const profile = await wallet.nostrProfile(target.pk);
const lnTarget = zapTargetFromProfile(profile, target.pk);
if (!lnTarget) throw new Error(t('lnZapNoAddress'));
- const t2 = { ...lnTarget, eventId: target.eventId || null };
+ const t2 = { ...lnTarget, eventId: target.eventId || null, moment: target.moment };
const p = await fetchPayParams(t2.url);
const msat = sats * 1000;
if (msat < p.minSendable || msat > p.maxSendable) {
@@ -206,10 +224,11 @@ export function zapsFeature(ctx) {
}
let zapRequest = null;
if (p.allowsNostr && t2.pk && wallet.nostrSign) {
- zapRequest = buildZapRequest({
+ const sign = await zapSigner();
+ zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32,
- recipientPk: t2.pk, eventId: t2.eventId || null, comment: '',
- signFn: (partial) => wallet.nostrSign(partial),
+ recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '',
+ signFn: sign,
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 });
@@ -264,10 +283,11 @@ export function zapsFeature(ctx) {
let zapRequest = null;
const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign;
if (canZap) {
- zapRequest = buildZapRequest({
+ const sign = await zapSigner();
+ zapRequest = await buildZapRequest({
amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32,
- recipientPk: z.target.pk, eventId: z.target.eventId || null, comment: z.comment,
- signFn: (partial) => wallet.nostrSign(partial),
+ recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment,
+ signFn: sign,
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment });
@@ -290,6 +310,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 +530,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);
@@ -455,12 +597,12 @@ export function zapsFeature(ctx) {
// Ark's "no Ark address published" screen offers a Lightning fallback,
// which lands here with the recipient's pubkey (and, when the zap was
// aimed at a note, its event id for the zap request's e tag).
- lnZapNpub(pk, npub, eventId, autoSat) {
+ lnZapNpub(pk, npub, eventId, autoSat, moment) {
if (!canPay() || !wallet.nostrProfile) return false;
if (autoSat && hook('arkReady')) {
// the chip on the zapped note is pulsing since the tap — tell it how
// this went, either way
- autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat)
+ autoZap({ kind: 'npub', pk, eventId: eventId || null, moment }, autoSat)
.then(() => hook('zapSettled', eventId, true, autoSat))
.catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + (e.message || t('lnZapFailed')), 4000); });
return true;
@@ -469,11 +611,11 @@ export function zapsFeature(ctx) {
hook('zapSettled', eventId, false);
if (eventId) { toast('⚡ ' + t('lnZapFailed'), 4000); return true; }
}
- begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
+ begin({ kind: 'npub', pk, eventId: eventId || null, moment }, 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..cf3323e 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;
@@ -54,6 +54,8 @@ export function createFeedCache(storage, prefix, now = Date.now) {
return value.notes;
} catch { return []; }
},
+ // a feed's posts as stored, without counting as a read
+ peekNotes(id) { const v = peek(id); return v && Array.isArray(v.notes) ? v.notes : null; },
save(id, notes) {
try { storage.setItem(keyOf(id), JSON.stringify({ at: now(), notes: compact(notes) })); sweep(keyOf(id)); } catch {}
},
diff --git a/src/hls-player.js b/src/hls-player.js
new file mode 100644
index 0000000..18292dd
--- /dev/null
+++ b/src/hls-player.js
@@ -0,0 +1,20 @@
+// HLS where the browser has none of its own (Firefox): hls.js feeds the
+// <video> through Media Source. Chrome and Safari play the playlist as a
+// plain src, so this file is fetched only when needed. The single-file
+// build replaces it with a stub (attachHls → null): no stream there.
+// A stream that's on (`live`) is held near its live edge: started 1.5 s
+// back, played up to 1.1× when it drifts past 5 s.
+export async function attachHls(video, src, { live = false } = {}) {
+ const { default: Hls } = await import('hls.js');
+ if (!Hls.isSupported()) return null;
+ const hls = new Hls({ lowLatencyMode: true, backBufferLength: 30,
+ ...(live ? { liveSyncDuration: 1.5, liveMaxLatencyDuration: 5, maxLiveSyncPlaybackRate: 1.1 } : {}) });
+ hls.loadSource(src);
+ hls.attachMedia(video);
+ hls.on(Hls.Events.ERROR, (_, d) => {
+ if (!d.fatal) return;
+ if (d.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); return; }
+ video.dispatchEvent(new Event('hlsfatal'));
+ });
+ return hls;
+}
diff --git a/src/i18n.js b/src/i18n.js
index 278d136..fdcd1e1 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -35,6 +35,9 @@ export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
+ mediaSeek: 'Seek',
+ mediaMomentDrop: 'About the whole post, not this moment',
+ mediaCommentAt: 'Comment at',
arkHistoryIncomplete: 'History incomplete',
arkDetailsNotSynced: 'Payment details not synced',
arkHistoryIncompleteHelp: 'These coins have been used, but the payment details and any remaining change have not synced to this device. Open the wallet that made the payments and let it sync.',
@@ -106,6 +109,17 @@ const STR = {
profDisplayName: 'Display name',
profPublicKey: 'Nostr public key',
profKeyLink: 'Public key',
+ profHistDesc: 'If another app replaced your profile, an older copy may still be on a relay that keeps history. Using one fills only the fields that are empty here.',
+ profHistLook: 'Look for older copies',
+ profHistUse: 'Use',
+ profHistNoName: '(no name)',
+ profHistNone: 'No older copy has anything this one is missing.',
+ profHistNothing: 'Everything in that copy is already here.',
+ profHistFilled: 'Filled {n} fields from the older copy. Save to publish.',
+ profHist_name: 'name',
+ profHist_about: 'bio',
+ profHist_picture: 'picture',
+ profHist_banner: 'banner',
followHistTitle: 'Follow list',
followHistDesc: 'You follow {n} people. If another app replaced your follow list, an older copy may still be on a relay that keeps history. Restoring adds everyone from it back; nobody is removed.',
followHistLook: 'Look for older copies',
@@ -224,6 +238,9 @@ const STR = {
moveToSavingDesc: 'Park money in ordinary on-chain Bitcoin.',
moveToSavingBtn: 'Move to Savings',
spendingLabel: 'Spending',
+ historyExportTitle: 'Transaction history',
+ historyExportDesc: 'Download every payment in Spending and Savings as a CSV file, for a spreadsheet or your accountant.',
+ historyExportBtn: 'Export CSV',
savingLabel: 'Savings',
balanceSwitch: 'Switch to {name}',
movingLabel: 'Moving…',
@@ -249,6 +266,7 @@ const STR = {
msgUploadFailed: 'Upload failed. Check your connection and try again',
mediaUploadTooBig: 'That file is too big for the available media hosts',
mediaUploadUnsupported: 'That file format is not supported',
+ mediaUploadUnauthorized: 'The media server denied this upload. Check that your Nostr identity is allowed to upload there.',
msgPhoto: 'Photo',
searchPeopleFor: 'Search people for {q}',
msgEmojiSearch: 'Search emoji…',
@@ -270,7 +288,13 @@ 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",
+ videoPlay: "Play",
+ videoPause: "Pause",
pagerFetchingVideo: "Fetching the next video…",
pagerFetchingPictures: "Fetching more pictures…",
pagerCaughtUp: "That’s everything for now",
@@ -279,6 +303,7 @@ const STR = {
pagerPrevPic: 'Previous picture',
pagerNextPic: 'Next picture',
videoNoPicture: "This browser can’t show this video’s picture",
+ videoGone: "This video couldn’t be loaded from its host",
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.',
@@ -303,6 +328,7 @@ const STR = {
currencyLabel: 'Show amounts in',
feedTitle: 'Feed',
feedFollowingName: 'Following',
+ noteReplyingTo: 'Replying to',
feedNew: 'New feed',
feedNewName: 'New feed',
feedEdit: 'Edit feed',
@@ -339,13 +365,16 @@ const STR = {
askSignInCreate: 'Create a new account',
askNotNow: 'Not now',
feedLangLabel: 'Language',
- mediaAll: "All",
+ mediaAll: "Notes",
+ mediaConversations: "Conversations",
mediaImages: "Images",
mediaVideos: "Videos",
+ mediaLive: "Live",
mediaArticles: "Articles",
mediaNoArticles: "No articles in this feed yet.",
mediaNoImages: "No pictures in this feed yet.",
mediaNoVideos: "No vertical videos in this feed yet.",
+ mediaNoLive: "Nobody in this feed is live right now.",
feedRelays: 'Relays',
feedRelaysFormHelp: 'Read this feed from these relays only. Leave empty for the usual ones.',
feedAllToggle: 'Every post on these relays (a firehose)',
@@ -424,6 +453,7 @@ const STR = {
profLists: 'Lists',
searchTopicRow: 'Posts on this topic',
searchTopicFor: 'Posts tagged #{q}',
+ relayLinkOpen: 'See what’s posted on this relay',
alertsTitle: 'Notifications',
alertsSub: 'Zaps, reactions and replies to your posts',
alertsNew: '{n} new',
@@ -447,6 +477,118 @@ const STR = {
feedEmpty: 'Nothing posted yet.',
feedLoadingMore: 'Loading more posts…',
feedEnd: 'You’re all caught up.',
+ liveBadge: 'LIVE',
+ livePlanned: 'SOON',
+ liveEnded: 'ENDED',
+ livePast: 'Past streams',
+ liveReport: 'Report this stream',
+ liveReportTitle: 'Report this stream',
+ liveReportDesc: 'coinos carries this stream. Tell us what’s wrong and we’ll look at it right away.',
+ liveReason_csam: 'Sexual content involving a minor',
+ liveReason_sexual: 'Sexual content',
+ liveReason_violence: 'Violence or gore',
+ liveReason_illegal: 'Something else illegal',
+ liveReason_spam: 'Spam or a scam',
+ liveReason_other: 'Something else',
+ liveReportTextPh: 'Anything we should know (optional)',
+ liveReportSend: 'Send report',
+ liveReported: 'Reported. Thank you.',
+ liveTakeDown: 'Take down',
+ liveTakeDownAsk: 'Take this stream down? It ends now, and its recording is kept from everyone (preserved for reporting).',
+ liveBanAsk: 'Also stop this account from streaming again?',
+ liveTakenDown: 'Taken down.',
+ liveTakenDownBanned: 'Taken down, and the account can’t stream again.',
+ liveRemoved: 'This stream was taken down.',
+ liveDelete: 'Delete recording',
+ liveDeleteAsk: 'Delete this stream’s recording? Nobody will be able to watch it again.',
+ liveDeleted: 'Recording deleted.',
+ liveDeletedSay: 'The streamer deleted this recording.',
+ goLiveNewName: 'Your name can go live a day after you claimed it: from {when}.',
+ goLiveBanned: 'This account can’t go live on coinos.',
+ liveWatchRecording: 'Watch the recording',
+ liveStaleMine: '“{title}” still says live, but nothing is going out.',
+ liveNotYours: 'Only the account that started this stream can end it.',
+ liveRecordingBadge: 'RECORDING',
+ liveUntitled: 'Live stream',
+ liveWatching: '{n} watching',
+ liveWatch: 'Watch',
+ liveZap: 'Zap',
+ liveChat: 'Chat',
+ liveChatEmpty: 'No messages yet.',
+ liveChatHint: 'Say something',
+ liveSend: 'Send',
+ liveZapped: 'zapped {n} sats',
+ liveSomeone: 'Someone',
+ liveOffline: 'The stream isn’t playing right now.',
+ liveNoPlayer: 'This browser can’t play the stream.',
+ liveOver: 'This stream has ended.',
+ liveStartsAt: 'Starts {when}',
+ liveNotFound: 'Couldn’t find that stream.',
+ goLive: 'Go live',
+ goLiveCamera: 'Camera',
+ goLiveScreen: 'Screen',
+ goLiveFlip: 'Flip',
+ goLiveTitlePh: 'What’s your stream about?',
+ goLiveDefaultTitle: '{name} is live',
+ goLiveStart: 'Go live',
+ goLiveStarting: 'Starting…',
+ goLiveAsking: 'Asking for the camera…',
+ goLiveNoCamera: 'The camera isn’t available. Check that the browser may use it.',
+ goLiveNoScreen: 'Screen sharing was cancelled or isn’t available.',
+ goLiveNeedsName: 'Going live needs a coinos name: claim one in Settings first.',
+ goLiveRefused: 'The live server didn’t take the stream.',
+ goLiveHint: 'Your stream is public: anyone can watch it and its recording, kept for a week. Keep this page open while you’re live.',
+ goLiveEnd: 'End',
+ goLiveEndAsk: 'End your stream?',
+ goLiveEnded: 'Your stream has ended.',
+ goLiveReconnecting: 'Reconnecting…',
+ goLiveBehind: 'Your connection is falling behind',
+ goLiveLost: 'Lost the connection to the live server.',
+ goLiveRestarted: 'The stream had to start over.',
+ callVoice: 'Voice call',
+ callVideo: 'Video call',
+ callIncomingVoice: 'Voice call',
+ callIncomingVideo: 'Video call',
+ callIncomingFrom: '{name} is calling',
+ callCalling: 'Calling…',
+ callConnecting: 'Connecting…',
+ callAccept: 'Answer',
+ callDecline: 'Decline',
+ callHangUp: 'Hang up',
+ callMute: 'Mute',
+ callUnmute: 'Unmute',
+ callCamOff: 'Turn camera off',
+ callCamOn: 'Turn camera on',
+ goLiveToCamera: 'Back to camera',
+ callShare: 'Share screen',
+ callStopShare: 'Stop sharing',
+ callAddVideo: 'Turn on camera',
+ callFlip: 'Switch camera',
+ callMinimize: 'Minimize',
+ callEnded: 'Call ended',
+ callNoAnswer: 'No answer',
+ callDeclined: 'Declined',
+ callBusy: 'They’re on another call',
+ callFailed: 'Couldn’t connect',
+ callNoPermission: 'The microphone or camera isn’t allowed',
+ callMissed: 'Missed call',
+ callAnsweredElsewhere: 'Answered on another device',
+ callDeclinedElsewhere: 'Declined on another device',
+ callUnsupported: 'This browser can’t make calls.',
+ notifCall: 'Calls',
+ callLogVoice: 'voice call',
+ callLogVideo: 'video call',
+ callLogOut: 'Outgoing {what}',
+ callLogIn: 'Incoming {what}',
+ callLogMissed: 'Missed {what}',
+ callLogDeclined: 'Declined {what}',
+ callLogTheyDeclined: 'Outgoing {what} · Declined',
+ callLogNoAnswer: 'Outgoing {what} · No answer',
+ callLogCancelled: 'Cancelled {what}',
+ callLogBusy: 'Outgoing {what} · Busy',
+ callLogFailed: '{what} · Couldn’t connect',
+ callLogUnrung: 'This didn’t ring: calls ring from people you follow or have written to.',
+ callLogUnrungWas: 'This didn’t ring: you didn’t follow them then.',
feedAttach: 'Attach a picture or video',
postLike: 'Like',
postBoost: 'Boost',
@@ -531,12 +673,20 @@ 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',
profComposePh: "What's on your mind?",
profPostBtn: 'Post',
profPosted: 'Posted',
+ schedToggle: 'Schedule for later',
+ schedAt: 'Post at',
+ schedBtn: 'Schedule',
+ schedDone: 'Scheduled for {when}',
+ schedTooSoon: 'Pick a time at least two minutes from now',
+ schedListTitle: 'Scheduled ({n})',
+ schedCancelled: 'Scheduled post cancelled',
publicChatTitle: '{name} community',
publicChatDesc: 'Live chat: read along, sign in to join the conversation',
publicChatSignIn: 'Sign in to chat',
@@ -556,8 +706,8 @@ const STR = {
addrRead: '{min} min read',
addrList: 'A list by {name}',
msgSignerOff: "Your nostr signer isn't connected, so you can't post as yourself yet.",
- msgAmberDms: 'Signed in with Amber, messages sent to you can’t be opened here, only ones you sent from this device.',
- msgAmberConnect: 'Connect Amber',
+ msgAmberDms: 'Signed in with a signer app, messages sent to you can’t be opened here, only ones you sent from this device.',
+ msgAmberConnect: 'Connect signer',
postRepostedBy: '{name} reposted',
gifBtnTitle: 'Add a GIF',
gifSearchPh: 'Search GIFs',
@@ -648,6 +798,31 @@ const STR = {
arkFeeChain: 'On-chain fee',
arkFeeService: 'Service fee',
arkCoinsExpired: 'expired',
+ arkCoinsHeld: 'held by Coinos',
+ arkCoopSelected: 'Only the {n} coins ticked above will move.',
+ arkCoopSelectedOne: 'Only the coin ticked above will move.',
+ arkCoopSelectedAmt: '{n} selected coins · {amount}',
+ arkCoopSelectedAmtOne: '1 selected coin · {amount}',
+ arkCustodyTitle: 'Custody strategy',
+ safeBootTitle: 'Coinos opened in safe mode',
+ safeBootBody: 'The app was closed {n} times in a row before it finished opening, so automatic connections are paused this time. Your coins and keys are untouched. Last step reached: {phase}.',
+ safeBootOpen: 'Open normally',
+ safeBootCopy: 'Copy report',
+ safeBootCopied: 'Copied',
+ arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos; after that, Coinos keeps honoring it. Renewing before expiry keeps your own exit, for a fee, so it is a choice:',
+ arkCustodyCustodial: 'Allow coins to expire',
+ arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation. The one exception: a coin whose payment history has grown very long is renewed free of the base fee, so it stays spendable.',
+ arkCustodySelf: 'Minimize renewal cost',
+ arkCustodySelfDesc: 'Coins renew in the last hours before they expire, when renewal is cheapest, so you can always exit on your own without Coinos. Roughly once every {days} days per coin. Coins too small to be worth the fee are left alone.',
+ arkCustodyExit: 'Minimize exit cost',
+ arkCustodyExitDesc: 'Every transfer adds a hop to a coin’s history, and a unilateral exit pays one on-chain transaction per hop. This renews a coin before it expires, and also as soon as its history is {hops} hops deep, so bringing any coin on-chain stays close to a fresh coin’s price. Only for coins worth it: never more than a tenth of a coin on its renewal.',
+ arkCustodyCostNone: 'no fees',
+ arkCustodyCostSelf: '{base} per renewal',
+ arkCustodyCostExit: '{base} near expiry, {pct} + {base} when early',
+ arkCustodySaved: 'Custody choice saved',
+ arkCustodyNotHere: 'This server does not honor expired coins, so they renew automatically here.',
+ arkExitNoneExitable: 'Every coin has expired, so there is nothing a unilateral exit can take on-chain: Coinos holds them now. Renewing restores your own exit; withdrawing with Coinos’s cooperation works as before.',
+ arkCoinsRenewDescCustodial: 'With “Allow coins to expire”, nothing renews on its own. A renewal replaces the selected coins with a single fresh one and restores your own exit for them. Every renewal costs a flat {base} toward its on-chain fee, plus a share that depends on how close each coin is to expiry. The closer, the cheaper:',
noteForeignKind: 'A kind {kind} event this app doesn’t show.',
zapRowText: 'zapped {n} sats',
pollVoter: '{n} vote',
@@ -671,8 +846,10 @@ const STR = {
arkRenewStageRetry: 'The last check with the server didn’t go through; the wallet keeps trying.',
arkRenewingHistory: 'Renewing',
arkRenewingOpen: 'See the coins',
- arkCoinsExitTitle: 'Unilateral exit',
- arkCoinsCoopTitle: 'Co-operative exit',
+ arkCoinsExitTitle: 'Exit',
+ arkCoinsCoopTitle: 'Withdraw',
+ arkCoinsWithdrawBtn: 'Withdraw {amount}',
+ arkCoinsExitBtnAmt: 'Exit {amount}',
arkOffboardBadAddress: 'Enter a valid on-chain address for this network.',
arkCoinsExitBtn: 'Exit now',
arkExitConfirmTitle: 'Start a unilateral exit?',
@@ -765,6 +942,7 @@ const STR = {
profNotesNone: 'No recent posts found.',
profTabPosts: 'Posts',
profTabReplies: 'Replies',
+ profTabStreams: 'Streams',
profRepliesNone: 'No recent replies found.',
profReplyTag: 'reply',
msgSearching: 'Searching…',
@@ -785,6 +963,12 @@ const STR = {
msgBadInvite: 'Not a valid invite link',
msgInviteLoading: 'Fetching invite…',
msgInviteNotFound: 'Invite not found on its relays',
+ mediaServersTitle: 'Media servers',
+ mediaServersHow: 'Blossom servers for chat attachments and post media, including videos. Enter up to 6 HTTPS server addresses, one per line. Custom servers replace the public upload defaults. Saved on this device; existing uploads stay where they are.',
+ mediaServersDefault: 'Using public media servers',
+ mediaServersSaved: 'Media servers saved',
+ mediaServersInvalid: 'Enter 1–6 HTTPS server addresses without paths, credentials, queries or fragments.',
+ mediaServersSaveFailed: 'Could not save media servers on this device.',
syncStorageTitle: 'Sync storage',
syncStorageHow: 'Wallet state too large for a relay event is encrypted and stored as a blob on these Blossom servers (one per line). Any Blossom server works. Run your own or use another provider.',
syncStorageSave: 'Save servers',
@@ -894,6 +1078,10 @@ const STR = {
giftWelcome: 'You’ve been sent Bitcoin',
giftBackedUp: 'I’ve written it down',
giftLink: 'Gift some Bitcoin',
+ giftNotePlaceholder: 'Add a note (optional)',
+ giftFromMe: 'Show it’s from me (whoever claims it follows you)',
+ giftFrom: 'From',
+ giftClaimFollows: 'Claiming it follows them',
giftLinkDesc: 'Presign an amount as a link. Whoever opens it claims the Bitcoin into a brand-new wallet that only they control.',
giftLinkWarn: '⚠ Anyone who opens this link can claim the amount, so share it only with the person you are gifting. Keep amounts small.',
giftLinkReveal: 'Create gift link',
@@ -957,6 +1145,7 @@ const STR = {
claimOr: 'or',
goToWallet: 'Go to wallet',
claimBtn: 'Claim it',
+ claimNowBtn: 'Claim it now',
claimFeeNote: '~{n} fee',
claimFailed: 'This gift has already been claimed or is no longer available.',
claimNotVisibleBody: 'The coins funding this gift aren\u2019t visible from this wallet\u2019s network/data source. Make sure this wallet uses the same network and data source as the sender, then try again.',
@@ -1223,6 +1412,8 @@ const STR = {
onbSpendBody: 'Claim a username and get your own payment address. People can send you bitcoin instantly, with just your name.',
onbNameWait: 'Getting things ready…',
onbNotNow: 'Not now',
+ onbGiftNameTitle: 'Now pick a username',
+ onbGiftNameBody: 'Claim a name and get your own payment address, so people can send you bitcoin with just your name. Then choose a picture and a cover.',
onbAvatarTitle: 'Choose your look',
onbAvatarBody: 'Pick a cover and an avatar, or upload your own. You can change them anytime.',
onbCover: 'Cover image',
@@ -1246,8 +1437,10 @@ const STR = {
nlSignInNostr: 'Sign in with Nostr',
nlDesc: 'Use your Nostr account to open your wallet, on any device, with the same account.',
nlExtension: 'Use browser extension',
- nlAmber: 'Sign in with Amber (Android)',
+ nlAmber: 'Sign in with a signer app (Android)',
nlAmberWait: 'Waiting for your signer app… approve there, then come back here.',
+ nlAmberTap: 'Your signer app has another request to answer. Open it to continue.',
+ nlAmberOpen: 'Open signer app',
nlAmberPasteHint: 'The answer is read from the clipboard when you return. If nothing happens, paste it here:',
nlAmberPastePh: 'Paste the signer’s answer',
nlAmberPasteBtn: 'Use it',
@@ -1256,7 +1449,7 @@ const STR = {
nlPasskey: 'Sign in with a passkey',
nlKeyOrBunker: 'nsec, private key, or bunker://…',
nlGo: 'Log in',
- nlHint: 'Works with Amber, Amethyst and other remote signers (paste their bunker:// address), or a browser extension. A pasted key is used once and never stored.',
+ nlHint: 'Works with remote signer apps (paste their bunker:// address) or a browser extension. A pasted key is used once and never stored.',
nlApprove: 'Approve the request in your signer →',
nlUnrecognized: 'That doesn’t look like a private key or a bunker:// address.',
nlNewTitle: 'Create a wallet for this account',
@@ -1277,7 +1470,7 @@ const STR = {
nlReconnectDesc: "Your signer isn't connected, so this app can't sign as you. Reconnect it to post and update your profile. Your wallet and balance aren't affected.",
nlReconnectTitle: 'Reconnect your signer',
nlAmberUpgradeTitle: 'Read your messages',
- nlAmberUpgradeBody: 'Amber can only open messages here when it’s connected as a remote signer. Tap below, approve in Amber (allow decrypting), and your messages will load. Signing works as before.',
+ nlAmberUpgradeBody: 'Your signer app can only open messages here when it’s connected as a remote signer. Tap below, approve in the signer app (allow decrypting), and your messages will load. Signing works as before.',
nlReconnectBody: 'Your Nostr signer isn’t connected on this device. Reconnect to keep acting as {npub}…',
nlWrongAccount: 'That signer is a different Nostr account. Reconnect with {npub}.',
nlLinked: 'Wallet linked to your Nostr account',
@@ -1309,6 +1502,7 @@ const STR = {
namesClaim: 'Claim',
namesClaimed: 'You are now {name}',
namesCopy: 'Copy address',
+ namesChangeTo: 'Change to {addr}',
namesMoreOpen: 'More options',
namesMoreTitle: 'More ways to get paid',
namesMoreBolt11: 'Lightning invoice (BOLT 11)',
@@ -1325,6 +1519,8 @@ const STR = {
namesInvMemoPh: 'Description (optional)',
namesInvCreate: 'Create invoice',
namesInvNew: 'New invoice',
+ namesInvKeepOpen: 'Keep coinos open until this is paid. If it\u2019s paid while coinos is closed, the money goes back to the sender. It expires in an hour.',
+ namesInvExpired: 'This invoice has expired. Make a new one when you\u2019re ready to be paid.',
namesInvBadAmount: 'Enter an amount in sats',
namesOfferMemoPh: 'Label (e.g. Ocean payout)',
namesOfferCreate: 'Create',
@@ -1430,6 +1626,9 @@ const STR = {
notifChatHint: 'Community chat is opt-in per room. Turn it on from a room’s menu.',
notifPaySent: 'Payments sent by connected apps',
notifPaySentHint: 'A heads-up when a connected app (a nostr client zapping, for example) spends from this wallet while it’s closed.',
+ nwcWorkerLog: 'Background activity log',
+ nwcWorkerLogHint: 'What the wallet did while it was closed: wake-ups, payments, notifications. Copy it when reporting a problem.',
+ nwcWorkerLogEmpty: 'Nothing recorded yet',
nwcNotifHint: 'You’ll be asked to allow notifications. The connection uses them to answer when Coinos is closed.',
nwcNotifNeeded: 'Without notifications the connection couldn’t answer when Coinos is closed, so it wasn’t added. Allow notifications and try again.',
nwcNotifBlocked: 'Notifications are blocked for this site. Allow them in your browser settings, then add the connection again.',
@@ -1443,6 +1642,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.',
@@ -1477,6 +1689,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/image-thumbnails.js b/src/image-thumbnails.js
index ffac4b9..2686dac 100644
--- a/src/image-thumbnails.js
+++ b/src/image-thumbnails.js
@@ -14,16 +14,21 @@ function thumbnailWorker() {
const result = { width: bitmap.width, height: bitmap.height, media: null, tile: null };
// GIFs must keep animating; a canvas copy would freeze their first frame.
if (!/gif/i.test(blob.type)) {
+ // The grid's tile: the picture in its own shape (justified rows),
+ // tileSize tall, its middle cut to between 1:2 and 2:1 — the most a
+ // tile stretches either way.
if (tileSize) {
- const side = Math.min(bitmap.width, bitmap.height), px = Math.min(tileSize, side);
- let square;
+ const W = bitmap.width, H = bitmap.height;
+ const cw = Math.min(W, H * 2), ch = Math.min(H, W * 2);
+ const th = Math.min(tileSize, ch), tw = Math.max(1, Math.round(th * cw / ch));
+ let cut;
try {
- square = await createImageBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
- { resizeWidth: px, resizeHeight: px, resizeQuality: 'high' });
- const canvas = new OffscreenCanvas(px, px);
- canvas.getContext('2d').drawImage(square, 0, 0);
+ cut = await createImageBitmap(bitmap, (W - cw) / 2, (H - ch) / 2, cw, ch,
+ { resizeWidth: tw, resizeHeight: th, resizeQuality: 'high' });
+ const canvas = new OffscreenCanvas(tw, th);
+ canvas.getContext('2d').drawImage(cut, 0, 0);
result.tile = await encode(canvas, 0.8);
- } catch {} finally { square?.close(); }
+ } catch {} finally { cut?.close(); }
}
if (width && bitmap.width > width * 1.25) {
try {
diff --git a/src/lang-guess.js b/src/lang-guess.js
index 6d62e30..6911f73 100644
--- a/src/lang-guess.js
+++ b/src/lang-guess.js
@@ -26,7 +26,7 @@ const STOP = {
en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '),
es: 'el la de que y en los es un una por con para las del se no como más pero'.split(' '),
fr: 'le la les de des et est un une pour que qui dans pas sur avec ce il nous vous'.split(' '),
- de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir'.split(' '),
+ de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir du wie man noch schon aber oder wenn hast habe bin bist kann nur mal jetzt hier sind wird'.split(' '),
pt: 'o a de que e do da em um uma para com não os as se mais por isso você'.split(' '),
id: 'yang dan di ini itu dengan untuk tidak dari ada saya akan ke juga bisa kita sudah atau kalau aku'.split(' '),
tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '),
@@ -37,6 +37,13 @@ const STOP = {
pl: 'i w nie na się z to że jest do co jak ale tak o po czy już'.split(' '),
};
const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)]));
+// Letters only some languages write: one counts as one more common word.
+// A short line has few of those ("Weißt Du wie man die kriegt?" had only
+// "die" before the list grew, and went untranslated as maybe-English).
+const MARKS = {
+ de: /[ßäöü]/i, es: /[ñ¿¡]/i, pt: /[ãõ]/i, fr: /[œèêëîïû]/i, tr: /[ğşı]/i, pl: /[łąęśźżń]/i,
+ vi: /[ạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹđ]/i,
+};
// The text a reader actually reads: no links, no nostr references, no tags.
export const proseOf = (text) => String(text || '')
@@ -67,6 +74,7 @@ export function guessLang(text) {
if (words.length < 4) return null;
const score = {};
for (const w of words) for (const [l, set] of Object.entries(STOPSETS)) if (set.has(w)) score[l] = (score[l] || 0) + 1;
+ for (const [l, re] of Object.entries(MARKS)) if (re.test(prose)) score[l] = (score[l] || 0) + 1;
const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
if (!ranked.length || ranked[0][1] < 2) return null;
if (ranked[1] && ranked[0][1] < ranked[1][1] * 1.5) return null;
diff --git a/src/live.js b/src/live.js
new file mode 100644
index 0000000..d31c548
--- /dev/null
+++ b/src/live.js
@@ -0,0 +1,101 @@
+// Live streams (NIP-53). A stream is a kind 30311 event: its title, cover,
+// where to watch (`streaming`, almost always an HLS playlist), whether it's
+// on (`status`), how many watch, and who's in it (p tags with a role). Its
+// chat is kind 1311, addressed to the stream with an `a` tag; zaps carry
+// that `a` too, which is how zap.stream and the rest count them.
+export const LIVE_KIND = 30311;
+export const LIVE_CHAT_KIND = 1311;
+// Where streams are announced: zap.stream, Shosho and the rest publish to
+// the big public relays (a 2026-10-18 survey found every live one on damus,
+// nos.lol and ditto, most on primal; relay.zap.stream held none).
+export const LIVE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub'];
+// A stream still marked live that hasn't been refreshed in an hour is over:
+// plenty of publishers never say `ended` (NIP-53 allows reading it so).
+export const LIVE_STALE_S = 3600;
+
+// https, or a stream on this machine (a test's, a streamer's own preview)
+const WATCHABLE = /^(https:\/\/\S+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/\S*)$/;
+const tag = (ev, k) => ((ev && ev.tags) || []).find((x) => x[0] === k)?.[1] || '';
+export const liveAddr = (ev) => LIVE_KIND + ':' + ev.pubkey + ':' + tag(ev, 'd');
+export const liveTitle = (ev) => tag(ev, 'title').trim();
+export const liveSummary = (ev) => tag(ev, 'summary').trim();
+export const liveImage = (ev) => { const u = tag(ev, 'image').trim(); return /^https:\/\/\S+$/.test(u) ? u : ''; };
+export const liveViewers = (ev) => { const n = parseInt(tag(ev, 'current_participants'), 10); return n >= 0 ? n : null; };
+export const liveStarts = (ev) => +tag(ev, 'starts') || ev.created_at;
+
+// The person streaming: the host named in its p tags (zap.stream signs the
+// event with its own key and names the streamer as Host), else its author.
+export function liveHost(ev) {
+ const p = (ev.tags || []).find((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '') && /^host$/i.test(x[3] || ''));
+ return p ? p[1] : ev.pubkey;
+}
+// Where to watch: an HLS playlist first (what browsers can play), else any https address.
+export function liveStream(ev) {
+ const urls = (ev.tags || []).filter((x) => x[0] === 'streaming' && WATCHABLE.test(x[1] || '')).map((x) => x[1]);
+ return urls.find((u) => /\.m3u8(\?|#|$)/i.test(u)) || urls[0] || '';
+}
+export function liveRecording(ev) {
+ const u = tag(ev, 'recording');
+ return WATCHABLE.test(u) ? u : '';
+}
+export function liveStatus(ev, now = Date.now() / 1000) {
+ const s = tag(ev, 'status').toLowerCase();
+ if (s === 'live') return now - ev.created_at > LIVE_STALE_S ? 'ended' : 'live';
+ return s === 'planned' ? 'planned' : 'ended';
+}
+export const isLiveNow = (ev, now) => liveStatus(ev, now) === 'live' && !!liveStream(ev);
+// The relays the stream names for its chat, then the ones it's announced on.
+export function liveRelays(ev) {
+ const named = (ev.tags || []).filter((x) => x[0] === 'relays').flatMap((x) => x.slice(1)).filter((r) => /^wss:\/\/\S+$/.test(r));
+ return [...new Set([...named.slice(0, 6), ...LIVE_RELAYS])];
+}
+// Newest copy of each stream (it's replaceable: the address is the stream).
+export function newestByAddr(evs) {
+ const out = new Map();
+ for (const ev of evs) {
+ if (!ev || ev.kind !== LIVE_KIND) continue;
+ const a = liveAddr(ev), cur = out.get(a);
+ if (!cur || cur.created_at < ev.created_at) out.set(a, ev);
+ }
+ return out;
+}
+
+// The zap on a chat line: a 9735 receipt's amount from its bolt11, its
+// sender and words from the zap request inside it; coinos's own Ark receipt
+// (9737) says them in tags. `decode` is the bolt11 decoder (millisats).
+export function zapLine(ev, decode) {
+ if (ev.kind === 9737) {
+ return { pk: tag(ev, 'P') || ev.pubkey, sats: +tag(ev, 'amount') || 0, text: ev.content || '' };
+ }
+ let req = null;
+ try { req = JSON.parse(tag(ev, 'description')); } catch {}
+ let sats = 0;
+ try { sats = Math.floor(Number(decode(tag(ev, 'bolt11'))?.amountMsat || 0) / 1000); } catch {}
+ if (!sats && req) sats = Math.floor((+((req.tags || []).find((x) => x[0] === 'amount') || [])[1] || 0) / 1000);
+ return { pk: (req && /^[0-9a-f]{64}$/.test(req.pubkey || '') && req.pubkey) || tag(ev, 'P') || '', sats, text: (req && req.content) || '' };
+}
+
+// Zaps aimed at a stream carry its address: the zap request (kind 9734) and
+// coinos's Ark receipt look the event they're zapping up here.
+const zapAddrs = new Map();
+export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); };
+export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null;
+
+// One current reaction per person/message, independent of relay arrival order.
+// A deletion only removes reactions signed by its own author.
+export function liveReactions(events) {
+ const deleted = new Set();
+ for (const ev of events) if (ev.kind === 5)
+ for (const tag of ev.tags || []) if (tag[0] === 'e') deleted.add(ev.pubkey + ':' + tag[1]);
+ const out = new Map();
+ for (const ev of events) {
+ if (ev.kind !== 7 || deleted.has(ev.pubkey + ':' + ev.id)) continue;
+ const target = (ev.tags || []).filter(t => t[0] === 'e').at(-1)?.[1];
+ if (!target) continue;
+ const by = out.get(target) || new Map();
+ const old = by.get(ev.pubkey);
+ if (!old || ev.created_at > old.created_at || (ev.created_at === old.created_at && ev.id > old.id)) by.set(ev.pubkey, ev);
+ out.set(target, by);
+ }
+ return out;
+}
diff --git a/src/lnurl.js b/src/lnurl.js
index 3f1b39b..134edcf 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -1,3 +1,4 @@
+import { momentTags } from './media-timeline.js';
// LNURL-pay + NIP-57 ("lightning zap") logic — no DOM, so it stays testable.
//
// The wallet already pays bolt11 invoices over a Boltz submarine swap; this
@@ -10,7 +11,12 @@
// (bolt11) → hand that to the swap engine. The recipient's server is the one
// that broadcasts the receipt; we only sign the request.
+import { zapAddrOf } from './live.js';
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 +49,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 +101,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 {
@@ -111,11 +124,15 @@ export async function fetchPayParams(url) {
}
// A signed kind-9734 zap request (NIP-57). signFn(partial) → finalized event.
-export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn }) {
+export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn, moment }) {
const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]];
if (lnurlBech32) tags.push(['lnurl', lnurlBech32]);
if (eventId) tags.push(['e', eventId]);
- return signFn({ kind: 9734, content: comment || '', tags });
+ // a live stream's address too, so its chat and zap.stream count the zap
+ const addr = zapAddrOf(eventId);
+ if (addr) tags.push(['a', addr]);
+ tags.push(...momentTags(moment));
+ return signFn({ kind: 9734, created_at: Math.floor(Date.now() / 1000), content: comment || '', tags });
}
// GET the callback for a bolt11. When zapRequest is present this is a real zap;
@@ -134,3 +151,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/locales/ar.json b/src/locales/ar.json
index f112568..a60f6ba 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -364,6 +364,10 @@
"giftWelcome": "لقد أُرسلت إليك عملة Bitcoin",
"giftBackedUp": "لقد دوّنتها",
"giftLink": "أهدِ بعض عملات Bitcoin",
+ "giftNotePlaceholder": "أضف ملاحظة (اختياري)",
+ "giftFromMe": "قل إنها مني (المطالبة بها تتابعك)",
+ "giftFrom": "من",
+ "giftClaimFollows": "المطالبة بها تعني متابعة المُرسِل",
"giftLinkDesc": "وقّع مبلغاً مسبقاً كرابط. من يفتحه يطالب بعملات Bitcoin في محفظة جديدة تماماً يتحكم بها وحده.",
"giftLinkWarn": "⚠ أي شخص يفتح هذا الرابط يمكنه المطالبة بالمبلغ، لذا شاركه فقط مع الشخص الذي تهديه إياه. أبقِ المبالغ صغيرة.",
"giftLinkReveal": "إنشاء رابط الإهداء",
@@ -425,6 +429,7 @@
"claimOr": "أو",
"goToWallet": "اذهب إلى المحفظة",
"claimBtn": "طالب بها",
+ "claimNowBtn": "استلمها الآن",
"claimFeeNote": "♪{n} الرسوم",
"claimFailed": "تمت المطالبة بهذه الهدية بالفعل أو لم تعد متاحة.",
"claimNotVisibleBody": "والعملات التي تمول هذه الهدية غير مرئية من مصدر هذه المحفظة/البيانات. تأكد من استخدام هذه المحفظة نفس الشبكة ومصدر البيانات كمرسل، ثم محاولة مرة أخرى.",
@@ -618,6 +623,8 @@
"onbSpendBody": "اطالب بلقب مستخدم والحصول على عنوان الدفع الخاص بك - الناس يمكن أن ترسل لك قليلا على الفور، مع مجرد اسمك.",
"onbNameWait": "جهز الأشياء",
"onbNotNow": "ليس الآن",
+ "onbGiftNameTitle": "الآن اختر اسم مستخدم",
+ "onbGiftNameBody": "احجز اسمًا واحصل على عنوان دفع خاص بك، ليتمكن الناس من إرسال البيتكوين إليك باسمك فقط. ثم اختر صورة وغلافًا.",
"onbAvatarTitle": "اختر مظهرك",
"onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت",
"onbUpload": "تحميل الصورة",
@@ -678,6 +685,7 @@
"namesClaim": "المطالبة",
"namesClaimed": "أنت الآن {name}",
"namesCopy": "عنوان الرسالة",
+ "namesChangeTo": "غيّره إلى {addr}",
"namesMoreOpen": "المزيد من الخيارات",
"namesMoreTitle": "المزيد من الطرق للحصول على أجر",
"namesMoreBolt11": "فاتورة البرق (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "لا توجد نسخة أقدم فيها أحد تفتقده قائمتك.",
"followHistNothing": "كل من في تلك النسخة موجود في قائمتك بالفعل.",
"followHistRestored": "تمت استعادة {n} متابَعًا.",
+ "profHistDesc": "إذا استبدل تطبيق آخر ملفك الشخصي، فقد تبقى نسخة أقدم على مرحّل يحتفظ بالسجل. استخدامها يملأ فقط الحقول الفارغة هنا.",
+ "profHistLook": "البحث عن نسخ أقدم",
+ "profHistUse": "استخدام",
+ "profHistNoName": "(بلا اسم)",
+ "profHistNone": "لا توجد نسخة أقدم فيها شيء ينقص هنا.",
+ "profHistNothing": "كل ما في تلك النسخة موجود هنا بالفعل.",
+ "profHistFilled": "تم ملء {n} حقول من النسخة الأقدم. احفظ للنشر.",
+ "profHist_name": "الاسم",
+ "profHist_about": "النبذة",
+ "profHist_picture": "الصورة",
+ "profHist_banner": "الغلاف",
"securityTitle": "الأمان",
"arkCoinsNone": "لا توجد عملات في الإنفاق بعد.",
"profKeyLink": "المفتاح العام",
@@ -910,6 +929,8 @@
"mediaNoImages": "لا صور في هذه الخلاصة بعد.",
"mediaNoVideos": "لا فيديوهات عمودية في هذه الخلاصة بعد.",
"videoTapToPlay": "انقر للتشغيل",
+ "videoPlay": "تشغيل",
+ "videoPause": "إيقاف مؤقت",
"videoLargeTitle": "الفيديوهات",
"videoLargeToggle": "تشغيل الفيديوهات الكبيرة تلقائيًا",
"videoLargeHelp": "الفيديوهات التي تزيد على {mb} ميغابايت أو تتجاوز 1080p تنتظر نقرة، حتى لا تستهلك الخلاصة بياناتك. الحد أقل على الاتصال البطيء أو عند توفير البيانات.",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "عرض معاملة الجولة",
"pagerFetchingVideo": "جارٍ جلب الفيديو التالي…",
"pagerFetchingPictures": "جارٍ جلب المزيد من الصور…",
- "pagerCaughtUp": "هذا كل شيء الآن"
+ "pagerCaughtUp": "هذا كل شيء الآن",
+ "schedToggle": "جدولة لوقت لاحق",
+ "schedAt": "النشر في",
+ "schedBtn": "جدولة",
+ "schedDone": "مجدول في {when}",
+ "schedTooSoon": "اختر وقتًا بعد دقيقتين على الأقل من الآن",
+ "schedListTitle": "مجدولة ({n})",
+ "schedCancelled": "أُلغي المنشور المجدول"
}
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 85ce58a..0f627b2 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -364,6 +364,10 @@
"giftWelcome": "আপনাকে Bitcoin পাঠানো হয়েছে",
"giftBackedUp": "আমি এটি লিখে রেখেছি",
"giftLink": "কিছু Bitcoin উপহার দিন",
+ "giftNotePlaceholder": "একটি নোট যোগ করুন (ঐচ্ছিক)",
+ "giftFromMe": "জানাও এটা আমার কাছ থেকে (দাবি করলে তোমাকে ফলো করবে)",
+ "giftFrom": "পাঠিয়েছেন",
+ "giftClaimFollows": "দাবি করলে তাঁকে ফলো করা হবে",
"giftLinkDesc": "একটি পরিমাণ লিঙ্ক হিসেবে আগে থেকে সই করুন। যে এটি খুলবে সে Bitcoin-টি একটি একদম নতুন ওয়ালেটে দাবি করে, যা কেবল সে-ই নিয়ন্ত্রণ করে।",
"giftLinkWarn": "⚠ যে কেউ এই লিঙ্ক খুললে পরিমাণটি দাবি করতে পারে, তাই কেবল যাকে উপহার দিচ্ছেন তার সাথেই এটি শেয়ার করুন। পরিমাণ কম রাখুন।",
"giftLinkReveal": "লিংক নির্মাণ করুন",
@@ -425,6 +429,7 @@
"claimOr": "অথবা",
"goToWallet": "ওয়ালেট যাও",
"claimBtn": "দাবি করুন",
+ "claimNowBtn": "এখনই নিন",
"claimFeeNote": "একটিও নয়{n} কাগজ",
"claimFailed": "এই উপহারটি আগেই দাবি করা হয়েছে অথবা আর পাওয়া যাচ্ছে না।",
"claimNotVisibleBody": "এই দানের অর্থ এই ওয়ালেটের নেটওয়ার্ক/ডাটা উৎস থেকে দেখা যাচ্ছে না। নিশ্চিত করুন যে এই ওয়ালেট একই নেটওয়ার্ক এবং ডাটা উৎস ব্যবহার করে, অতঃপর পুনরায় চেষ্টা করুন ।",
@@ -618,6 +623,8 @@
"onbSpendBody": "আপনার নাম শুধু আপনার নামের সঙ্গে সঙ্গে সঙ্গে সঙ্গে আপনি আপনার পেকেন পাঠাতে পারেন ।",
"onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...",
"onbNotNow": "এই মুহূর্তে নয়",
+ "onbGiftNameTitle": "এবার একটি ইউজারনেম বেছে নিন",
+ "onbGiftNameBody": "একটি নাম নিন এবং নিজের পেমেন্ট ঠিকানা পান, যাতে লোকে শুধু আপনার নাম দিয়েই আপনাকে বিটকয়েন পাঠাতে পারে। তারপর একটি ছবি আর একটি কভার বেছে নিন।",
"onbAvatarTitle": "আপনার চেহারা পছন্দ করুন",
"onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।",
"onbUpload": "একটি ছবি আপলোড করুন",
@@ -678,6 +685,7 @@
"namesClaim": "মুছে ফেলুন (_D)",
"namesClaimed": "তুমি এখন {name}",
"namesCopy": "ঠিকানা কপি করো",
+ "namesChangeTo": "{addr}-এ বদলান",
"namesMoreOpen": "অতিরিক্ত বিকল্প",
"namesMoreTitle": "আরো টাকা পেতে",
"namesMoreBolt11": "(বিওএল ১১)",
@@ -878,6 +886,17 @@
"followHistNone": "কোনো পুরোনো কপিতে এমন কেউ নেই যিনি আপনার তালিকায় নেই।",
"followHistNothing": "ওই কপির সবাই ইতিমধ্যে আপনার তালিকায় আছেন।",
"followHistRestored": "{n} জনকে আবার ফলো করা হয়েছে।",
+ "profHistDesc": "অন্য কোনো অ্যাপ আপনার প্রোফাইল বদলে দিলে, ইতিহাস রাখে এমন কোনো রিলেতে পুরোনো কপি এখনও থাকতে পারে। সেটি ব্যবহার করলে এখানে শুধু খালি ঘরগুলো পূরণ হয়।",
+ "profHistLook": "পুরোনো কপি খুঁজুন",
+ "profHistUse": "ব্যবহার করুন",
+ "profHistNoName": "(নাম নেই)",
+ "profHistNone": "কোনো পুরোনো কপিতে এমন কিছু নেই যা এখানে নেই।",
+ "profHistNothing": "ওই কপির সবকিছু এখানে আগে থেকেই আছে।",
+ "profHistFilled": "পুরোনো কপি থেকে {n}টি ঘর পূরণ হয়েছে। প্রকাশ করতে সংরক্ষণ করুন।",
+ "profHist_name": "নাম",
+ "profHist_about": "পরিচিতি",
+ "profHist_picture": "ছবি",
+ "profHist_banner": "ব্যানার",
"securityTitle": "নিরাপত্তা",
"arkCoinsNone": "স্পেন্ডিং-এ এখনও কোনো কয়েন নেই।",
"profKeyLink": "পাবলিক কী",
@@ -910,6 +929,8 @@
"mediaNoImages": "এই ফিডে এখনও কোনো ছবি নেই।",
"mediaNoVideos": "এই ফিডে এখনও কোনো খাড়া ভিডিও নেই।",
"videoTapToPlay": "চালাতে ট্যাপ করুন",
+ "videoPlay": "চালান",
+ "videoPause": "বিরতি",
"videoLargeTitle": "ভিডিও",
"videoLargeToggle": "বড় ভিডিও নিজে থেকে চালান",
"videoLargeHelp": "{mb} MB-এর বেশি বা 1080p-এর ওপরের ভিডিও ট্যাপের অপেক্ষায় থাকে, যাতে ফিড আপনার ডেটা না খায়। ধীর বা ডেটা-সেভার সংযোগে সীমা কম।",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "রাউন্ডের লেনদেন দেখুন",
"pagerFetchingVideo": "পরের ভিডিও আনা হচ্ছে…",
"pagerFetchingPictures": "আরও ছবি আনা হচ্ছে…",
- "pagerCaughtUp": "আপাতত এটুকুই"
+ "pagerCaughtUp": "আপাতত এটুকুই",
+ "schedToggle": "পরে পোস্ট করার সময় ঠিক করুন",
+ "schedAt": "পোস্টের সময়",
+ "schedBtn": "সময় ঠিক করুন",
+ "schedDone": "{when}-এ নির্ধারিত",
+ "schedTooSoon": "এখন থেকে অন্তত দুই মিনিট পরের সময় বেছে নিন",
+ "schedListTitle": "নির্ধারিত ({n})",
+ "schedCancelled": "নির্ধারিত পোস্ট বাতিল হয়েছে"
}
diff --git a/src/locales/de.json b/src/locales/de.json
index 0210332..11fde22 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -364,6 +364,10 @@
"giftWelcome": "Dir wurde Bitcoin gesendet",
"giftBackedUp": "Ich habe sie aufgeschrieben",
"giftLink": "Bitcoin verschenken",
+ "giftNotePlaceholder": "Notiz hinzufügen (optional)",
+ "giftFromMe": "Zeigen, dass es von mir ist (wer es einlöst, folgt dir)",
+ "giftFrom": "Von",
+ "giftClaimFollows": "Beim Einlösen folgst du dieser Person",
"giftLinkDesc": "Signiere einen Betrag im Voraus als Link. Wer ihn öffnet, beansprucht das Bitcoin in einer brandneuen Wallet, die nur er selbst kontrolliert.",
"giftLinkWarn": "⚠ Wer diesen Link öffnet, kann den Betrag beanspruchen. Teile ihn daher nur mit der Person, die du beschenkst. Halte die Beträge klein.",
"giftLinkReveal": "Geschenk-Link erstellen",
@@ -425,6 +429,7 @@
"claimOr": "oder",
"goToWallet": "Zur Brieftasche gehen",
"claimBtn": "Beanspruchen",
+ "claimNowBtn": "Jetzt einlösen",
"claimFeeNote": "~{n} Gebühr",
"claimFailed": "Dieses Geschenk wurde bereits beansprucht oder ist nicht mehr verfügbar.",
"claimNotVisibleBody": "Die Münzen finanzieren dieses Geschenk sind nicht sichtbar aus dieser Brieftasche Netzwerk / Datenquelle. Stellen Sie sicher, dass diese Brieftasche die gleiche Netzwerk- und Datenquelle wie der Absender verwendet, dann versuchen Sie es erneut.",
@@ -618,6 +623,8 @@
"onbSpendBody": "Fordern Sie einen Benutzernamen an und erhalten Sie Ihre eigene Zahlungsadresse Die Leute können Ihnen Bitcoin sofort mit Ihrem Namen senden.",
"onbNameWait": "Bereitmachen...",
"onbNotNow": "Nicht jetzt",
+ "onbGiftNameTitle": "Wähle jetzt einen Benutzernamen",
+ "onbGiftNameBody": "Sichere dir einen Namen und deine eigene Zahlungsadresse, damit man dir Bitcoin einfach an deinen Namen senden kann. Danach wählst du ein Bild und ein Titelbild.",
"onbAvatarTitle": "Wählen Sie Ihren Look",
"onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.",
"onbUpload": "Foto hochladen",
@@ -640,7 +647,7 @@
"nlPasskey": "Anmelden mit einem Passkey",
"nlKeyOrBunker": "nsec, private Schlüssel oder Bunker://...",
"nlGo": "Anmelden",
- "nlHint": "Funktioniert mit Amber, Amethyst und anderen Remote-Signern (paste ihre Bunker://-Adresse), oder einer Browser-Erweiterung. Ein geklebter Schlüssel wird einmal verwendet und nie gespeichert.",
+ "nlHint": "Funktioniert mit Remote-Signer-Apps (füge ihre bunker://-Adresse ein) oder einer Browser-Erweiterung. Ein eingefügter Schlüssel wird einmal verwendet und nie gespeichert.",
"nlApprove": "billigt den Antrag in Ihrem Schilder →",
"nlUnrecognized": "Das sieht nicht aus wie ein privater Schlüssel oder eine Bunkeradresse.",
"nlNewTitle": "Erstellen Sie eine Brieftasche für dieses Konto",
@@ -678,6 +685,7 @@
"namesClaim": "Anspruch",
"namesClaimed": "Du bist jetzt {name}",
"namesCopy": "Adresse kopieren",
+ "namesChangeTo": "Ändern zu {addr}",
"namesMoreOpen": "Weitere Optionen",
"namesMoreTitle": "Mehr Möglichkeiten, um bezahlt zu werden",
"namesMoreBolt11": "Blitzrechnung (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "Keine ältere Kopie enthält jemanden, der in deiner Liste fehlt.",
"followHistNothing": "Alle aus dieser Kopie sind schon in deiner Liste.",
"followHistRestored": "{n} Follows wiederhergestellt.",
+ "profHistDesc": "Hat eine andere App dein Profil ersetzt, liegt vielleicht noch eine ältere Kopie auf einem Relay, das den Verlauf aufbewahrt. Eine zu übernehmen füllt nur die Felder, die hier leer sind.",
+ "profHistLook": "Nach älteren Kopien suchen",
+ "profHistUse": "Übernehmen",
+ "profHistNoName": "(kein Name)",
+ "profHistNone": "Keine ältere Kopie enthält etwas, das hier fehlt.",
+ "profHistNothing": "Alles aus dieser Kopie ist schon hier.",
+ "profHistFilled": "{n} Felder aus der älteren Kopie gefüllt. Speichern, um zu veröffentlichen.",
+ "profHist_name": "Name",
+ "profHist_about": "Bio",
+ "profHist_picture": "Bild",
+ "profHist_banner": "Banner",
"securityTitle": "Sicherheit",
"arkCoinsNone": "Noch keine Coins in Ausgaben.",
"profKeyLink": "Öffentlicher Schlüssel",
@@ -910,6 +929,8 @@
"mediaNoImages": "Noch keine Bilder in diesem Feed.",
"mediaNoVideos": "Noch keine Hochkant-Videos in diesem Feed.",
"videoTapToPlay": "Zum Abspielen tippen",
+ "videoPlay": "Abspielen",
+ "videoPause": "Pause",
"videoLargeTitle": "Videos",
"videoLargeToggle": "Große Videos automatisch abspielen",
"videoLargeHelp": "Videos über {mb} MB oder über 1080p warten auf einen Tipp, damit der Feed nicht dein Datenvolumen frisst. Bei langsamer Verbindung oder Datensparmodus ist die Grenze niedriger.",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "Transaktion der Runde ansehen",
"pagerFetchingVideo": "Nächstes Video wird geladen …",
"pagerFetchingPictures": "Weitere Bilder werden geladen …",
- "pagerCaughtUp": "Das ist vorerst alles"
+ "pagerCaughtUp": "Das ist vorerst alles",
+ "schedToggle": "Für später planen",
+ "schedAt": "Posten am",
+ "schedBtn": "Planen",
+ "schedDone": "Geplant für {when}",
+ "schedTooSoon": "Wähle eine Zeit mindestens zwei Minuten ab jetzt",
+ "schedListTitle": "Geplant ({n})",
+ "schedCancelled": "Geplanter Beitrag abgebrochen"
}
diff --git a/src/locales/es.json b/src/locales/es.json
index 9637b0a..28e2489 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -364,6 +364,10 @@
"giftWelcome": "Te han enviado Bitcoin",
"giftBackedUp": "Ya la anoté",
"giftLink": "Regala algo de Bitcoin",
+ "giftNotePlaceholder": "Añade una nota (opcional)",
+ "giftFromMe": "Decir que es mío (quien lo reclame te seguirá)",
+ "giftFrom": "De",
+ "giftClaimFollows": "Al reclamarlo, seguirás a esta persona",
"giftLinkDesc": "Prefirma un importe como enlace. Quien lo abra reclama el Bitcoin en una billetera totalmente nueva que solo esa persona controla.",
"giftLinkWarn": "⚠ Cualquiera que abra este enlace puede reclamar el importe, así que compártelo solo con la persona a quien se lo regalas. Mantén los importes pequeños.",
"giftLinkReveal": "Crear enlace de regalo",
@@ -425,6 +429,7 @@
"claimOr": "o",
"goToWallet": "Ve a la cartera.",
"claimBtn": "Reclamarlo",
+ "claimNowBtn": "Reclámalo ahora",
"claimFeeNote": "~{n} tarifa",
"claimFailed": "Este regalo ya ha sido reclamado o ya no está disponible.",
"claimNotVisibleBody": "Las monedas que financian este regalo no son visibles desde la fuente de red/datos de esta cartera. Asegúrese de que esta cartera utiliza la misma red y fuente de datos que el remitente, a continuación, inténtelo de nuevo.",
@@ -618,6 +623,8 @@
"onbSpendBody": "Reclame un nombre de usuario y obtenga su propia dirección de pago, la gente puede enviarle bitcoin al instante, con sólo su nombre.",
"onbNameWait": "Preparando las cosas...",
"onbNotNow": "Ahora no.",
+ "onbGiftNameTitle": "Ahora elige un nombre de usuario",
+ "onbGiftNameBody": "Reclama un nombre y obtén tu propia dirección de pago, para que te puedan enviar bitcoin solo con tu nombre. Después elige una foto y una portada.",
"onbAvatarTitle": "Elige tu look",
"onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.",
"onbUpload": "Sube una foto",
@@ -640,7 +647,7 @@
"nlPasskey": "Inicie sesión con un passkey",
"nlKeyOrBunker": "nsec, private key, or bunker://...",
"nlGo": "Iniciar sesión",
- "nlHint": "Trabaja con Amber, Amethyst y otros firmantes remotos (pasar su bunker:// dirección), o una extensión del navegador. Una llave pegada se utiliza una vez y nunca se almacena.",
+ "nlHint": "Funciona con apps firmantes remotas (pega su dirección bunker://) o una extensión del navegador. Una clave pegada se usa una vez y nunca se guarda.",
"nlApprove": "Aprobar la solicitud en su firmante →",
"nlUnrecognized": "Eso no parece una llave privada o una dirección bunker://.",
"nlNewTitle": "Crear una cartera para esta cuenta",
@@ -678,6 +685,7 @@
"namesClaim": "Reclamación",
"namesClaimed": "Ahora sí. {name}",
"namesCopy": "Dirección de copia",
+ "namesChangeTo": "Cambiar a {addr}",
"namesMoreOpen": "Más opciones",
"namesMoreTitle": "Más formas de pagar",
"namesMoreBolt11": "Factura de relámpago (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "Ninguna copia anterior tiene a alguien que falte en tu lista.",
"followHistNothing": "Todos los de esa copia ya están en tu lista.",
"followHistRestored": "Se restauraron {n} seguidos.",
+ "profHistDesc": "Si otra app reemplazó tu perfil, puede que aún haya una copia anterior en un relay que guarda el historial. Usarla solo rellena los campos que aquí están vacíos.",
+ "profHistLook": "Buscar copias anteriores",
+ "profHistUse": "Usar",
+ "profHistNoName": "(sin nombre)",
+ "profHistNone": "Ninguna copia anterior tiene algo que falte aquí.",
+ "profHistNothing": "Todo lo de esa copia ya está aquí.",
+ "profHistFilled": "{n} campos rellenados desde la copia anterior. Guarda para publicar.",
+ "profHist_name": "nombre",
+ "profHist_about": "bio",
+ "profHist_picture": "foto",
+ "profHist_banner": "banner",
"securityTitle": "Seguridad",
"arkCoinsNone": "Aún no hay monedas en Gastos.",
"profKeyLink": "Clave pública",
@@ -910,6 +929,8 @@
"mediaNoImages": "Aún no hay imágenes en este feed.",
"mediaNoVideos": "Aún no hay vídeos verticales en este feed.",
"videoTapToPlay": "Toca para reproducir",
+ "videoPlay": "Reproducir",
+ "videoPause": "Pausa",
"videoLargeTitle": "Vídeos",
"videoLargeToggle": "Reproducir vídeos grandes automáticamente",
"videoLargeHelp": "Los vídeos de más de {mb} MB o por encima de 1080p esperan a que los toques, para que el feed no se coma tus datos. El límite es menor con una conexión lenta o en ahorro de datos.",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "Ver la transacción de la ronda",
"pagerFetchingVideo": "Cargando el siguiente vídeo…",
"pagerFetchingPictures": "Cargando más imágenes…",
- "pagerCaughtUp": "Eso es todo por ahora"
+ "pagerCaughtUp": "Eso es todo por ahora",
+ "schedToggle": "Programar para más tarde",
+ "schedAt": "Publicar el",
+ "schedBtn": "Programar",
+ "schedDone": "Programado para {when}",
+ "schedTooSoon": "Elige una hora al menos dos minutos a partir de ahora",
+ "schedListTitle": "Programadas ({n})",
+ "schedCancelled": "Publicación programada cancelada"
}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 280be04..54c6eda 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -364,6 +364,10 @@
"giftWelcome": "Vous avez reçu des Bitcoin",
"giftBackedUp": "Je l’ai notée",
"giftLink": "Offrir des Bitcoin",
+ "giftNotePlaceholder": "Ajouter un mot (facultatif)",
+ "giftFromMe": "Dire que ça vient de moi (qui le réclame vous suivra)",
+ "giftFrom": "De",
+ "giftClaimFollows": "En le réclamant, vous suivrez cette personne",
"giftLinkDesc": "Présignez un montant sous forme de lien. Celui qui l’ouvre récupère les Bitcoin dans un tout nouveau portefeuille qu’il est seul à contrôler.",
"giftLinkWarn": "⚠ Quiconque ouvre ce lien peut récupérer le montant, alors ne le partagez qu’avec la personne à qui vous l’offrez. Gardez les montants faibles.",
"giftLinkReveal": "Créer le lien cadeau",
@@ -425,6 +429,7 @@
"claimOr": "ou",
"goToWallet": "Allez dans le portefeuille",
"claimBtn": "Récupérer",
+ "claimNowBtn": "Récupérer maintenant",
"claimFeeNote": "~{n} Taxe",
"claimFailed": "Ce cadeau a déjà été réclamé ou n’est plus disponible.",
"claimNotVisibleBody": "Les pièces de monnaie qui financent ce cadeau ne sont pas visibles à partir de ce portefeuille. Assurez-vous que ce portefeuille utilise le même réseau et la même source de données que l'expéditeur, puis essayez à nouveau.",
@@ -618,6 +623,8 @@
"onbSpendBody": "Réclamez un nom d'utilisateur et obtenez votre propre adresse de paiement, les gens peuvent vous envoyer Bitcoin instantanément, avec juste votre nom.",
"onbNameWait": "Préparer les choses...",
"onbNotNow": "Pas maintenant.",
+ "onbGiftNameTitle": "Choisissez maintenant un nom d'utilisateur",
+ "onbGiftNameBody": "Réservez un nom et obtenez votre propre adresse de paiement, pour qu'on puisse vous envoyer des bitcoins avec votre seul nom. Choisissez ensuite une photo et une couverture.",
"onbAvatarTitle": "Choisissez votre look",
"onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.",
"onbUpload": "Télécharger une photo",
@@ -640,7 +647,7 @@
"nlPasskey": "Connectez-vous avec un mot de passe",
"nlKeyOrBunker": "nsec, clé privée, ou bunker://...",
"nlGo": "Connexion",
- "nlHint": "Fonctionne avec Amber, Amethyst et d'autres signataires distants (collez leur adresse de bunker://), ou une extension de navigateur. Une clé collée est utilisée une fois et jamais stockée.",
+ "nlHint": "Fonctionne avec les applis de signature à distance (collez leur adresse bunker://) ou une extension de navigateur. Une clé collée est utilisée une fois et jamais enregistrée.",
"nlApprove": "Approuver la demande dans votre signataire →",
"nlUnrecognized": "Ça ne ressemble pas à une clé privée ou à une adresse de bunker://.",
"nlNewTitle": "Créer un portefeuille pour ce compte",
@@ -678,6 +685,7 @@
"namesClaim": "Réclamation",
"namesClaimed": "Vous êtes maintenant {name}",
"namesCopy": "Copier l'adresse",
+ "namesChangeTo": "Passer à {addr}",
"namesMoreOpen": "Autres options",
"namesMoreTitle": "Plus de moyens de se faire payer",
"namesMoreBolt11": "Facture de foudre (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "Aucune copie plus ancienne ne contient quelqu’un qui manque à votre liste.",
"followHistNothing": "Tout le monde dans cette copie est déjà dans votre liste.",
"followHistRestored": "{n} abonnements restaurés.",
+ "profHistDesc": "Si une autre app a remplacé votre profil, une copie plus ancienne est peut-être encore sur un relais qui garde l’historique. En utiliser une ne remplit que les champs vides ici.",
+ "profHistLook": "Chercher des copies plus anciennes",
+ "profHistUse": "Utiliser",
+ "profHistNoName": "(sans nom)",
+ "profHistNone": "Aucune copie plus ancienne n’a quelque chose qui manque ici.",
+ "profHistNothing": "Tout ce que contient cette copie est déjà ici.",
+ "profHistFilled": "{n} champs remplis depuis la copie plus ancienne. Enregistrez pour publier.",
+ "profHist_name": "nom",
+ "profHist_about": "bio",
+ "profHist_picture": "photo",
+ "profHist_banner": "bannière",
"securityTitle": "Sécurité",
"arkCoinsNone": "Aucune pièce dans Dépenses pour l’instant.",
"profKeyLink": "Clé publique",
@@ -910,6 +929,8 @@
"mediaNoImages": "Pas encore d’images dans ce fil.",
"mediaNoVideos": "Pas encore de vidéos verticales dans ce fil.",
"videoTapToPlay": "Touchez pour lire",
+ "videoPlay": "Lire",
+ "videoPause": "Pause",
"videoLargeTitle": "Vidéos",
"videoLargeToggle": "Lire automatiquement les grosses vidéos",
"videoLargeHelp": "Les vidéos de plus de {mb} Mo ou au-delà de 1080p attendent un toucher, pour que le fil ne dévore pas vos données. La limite est plus basse sur une connexion lente ou en économie de données.",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "Voir la transaction du tour",
"pagerFetchingVideo": "Chargement de la vidéo suivante…",
"pagerFetchingPictures": "Chargement d’autres images…",
- "pagerCaughtUp": "C’est tout pour le moment"
+ "pagerCaughtUp": "C’est tout pour le moment",
+ "schedToggle": "Programmer pour plus tard",
+ "schedAt": "Publier le",
+ "schedBtn": "Programmer",
+ "schedDone": "Programmé pour {when}",
+ "schedTooSoon": "Choisis une heure au moins deux minutes après maintenant",
+ "schedListTitle": "Programmés ({n})",
+ "schedCancelled": "Publication programmée annulée"
}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 8057344..527eb27 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -364,6 +364,10 @@
"giftWelcome": "आपको Bitcoin भेजा गया है",
"giftBackedUp": "मैंने इसे लिख लिया है",
"giftLink": "कुछ Bitcoin उपहार में दें",
+ "giftNotePlaceholder": "एक नोट जोड़ें (वैकल्पिक)",
+ "giftFromMe": "बताएं कि यह मेरी ओर से है (दावा करने पर आपको फ़ॉलो करेंगे)",
+ "giftFrom": "भेजने वाले",
+ "giftClaimFollows": "दावा करने पर आप उन्हें फ़ॉलो करेंगे",
"giftLinkDesc": "एक राशि को लिंक के रूप में पहले से साइन करें। जो भी इसे खोलेगा, वह Bitcoin को एक बिल्कुल नए वॉलेट में प्राप्त कर लेगा जिसे केवल वही नियंत्रित करता है।",
"giftLinkWarn": "⚠ जो भी इस लिंक को खोलेगा वह राशि प्राप्त कर सकता है, इसलिए इसे केवल उसी व्यक्ति के साथ साझा करें जिसे आप उपहार दे रहे हैं। राशि कम रखें।",
"giftLinkReveal": "उपहार लिंक बनाएँ",
@@ -425,6 +429,7 @@
"claimOr": "या",
"goToWallet": "वॉलेट पर जाएं",
"claimBtn": "इसे प्राप्त करें",
+ "claimNowBtn": "अभी लें",
"claimFeeNote": "~{n} शुल्क",
"claimFailed": "इस उपहार का दावा पहले ही किया जा चुका है या यह अब उपलब्ध नहीं है।",
"claimNotVisibleBody": "इस उपहार को वित्तपोषित करने वाले सिक्के इस वॉलेट के नेटवर्क / डेटा स्रोत से दिखाई नहीं देते हैं। सुनिश्चित करें कि यह वॉलेट प्रेषक के समान नेटवर्क और डेटा स्रोत का उपयोग करता है, फिर फिर से प्रयास करें।",
@@ -618,6 +623,8 @@
"onbSpendBody": "एक उपयोगकर्ता नाम का दावा करें और अपना खुद का भुगतान पता प्राप्त करें - लोग तुरंत अपने नाम के साथ बिटकॉइन भेज सकते हैं।",
"onbNameWait": "चीजों को तैयार करना",
"onbNotNow": "अब नहीं",
+ "onbGiftNameTitle": "अब एक यूज़रनेम चुनें",
+ "onbGiftNameBody": "एक नाम लें और अपना भुगतान पता पाएं, ताकि लोग सिर्फ़ आपके नाम से आपको बिटकॉइन भेज सकें। फिर एक तस्वीर और एक कवर चुनें।",
"onbAvatarTitle": "अपना लुक चुनें",
"onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।",
"onbUpload": "फ़ोटो अपलोड करें",
@@ -678,6 +685,7 @@
"namesClaim": "दावा",
"namesClaimed": "अब {name}",
"namesCopy": "पता कॉपी करें",
+ "namesChangeTo": "{addr} में बदलें",
"namesMoreOpen": "अधिक विकल्प",
"namesMoreTitle": "भुगतान करने के तरीके",
"namesMoreBolt11": "बिजली चालान (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "किसी पुरानी कॉपी में ऐसा कोई नहीं जो आपकी सूची में न हो।",
"followHistNothing": "उस कॉपी के सभी लोग पहले से आपकी सूची में हैं।",
"followHistRestored": "{n} फ़ॉलो बहाल किए गए।",
+ "profHistDesc": "अगर किसी दूसरे ऐप ने आपकी प्रोफ़ाइल बदल दी है, तो इतिहास रखने वाले किसी रिले पर पुरानी प्रति अब भी हो सकती है। उसे इस्तेमाल करने से यहाँ सिर्फ़ खाली फ़ील्ड भरते हैं।",
+ "profHistLook": "पुरानी प्रतियाँ खोजें",
+ "profHistUse": "इस्तेमाल करें",
+ "profHistNoName": "(बिना नाम)",
+ "profHistNone": "किसी पुरानी प्रति में ऐसा कुछ नहीं जो यहाँ न हो।",
+ "profHistNothing": "उस प्रति की हर चीज़ यहाँ पहले से है।",
+ "profHistFilled": "पुरानी प्रति से {n} फ़ील्ड भरे गए। प्रकाशित करने के लिए सहेजें।",
+ "profHist_name": "नाम",
+ "profHist_about": "परिचय",
+ "profHist_picture": "फ़ोटो",
+ "profHist_banner": "बैनर",
"securityTitle": "सुरक्षा",
"arkCoinsNone": "स्पेंडिंग में अभी कोई कॉइन नहीं।",
"profKeyLink": "पब्लिक की",
@@ -910,6 +929,8 @@
"mediaNoImages": "इस फ़ीड में अभी कोई तस्वीर नहीं।",
"mediaNoVideos": "इस फ़ीड में अभी कोई वर्टिकल वीडियो नहीं।",
"videoTapToPlay": "चलाने के लिए टैप करें",
+ "videoPlay": "चलाएँ",
+ "videoPause": "रोकें",
"videoLargeTitle": "वीडियो",
"videoLargeToggle": "बड़े वीडियो अपने आप चलाएँ",
"videoLargeHelp": "{mb} MB से बड़े या 1080p से ऊपर के वीडियो टैप का इंतज़ार करते हैं, ताकि फ़ीड आपका डेटा न खाए। धीमे या डेटा-सेवर कनेक्शन पर सीमा कम होती है।",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "राउंड का लेनदेन देखें",
"pagerFetchingVideo": "अगला वीडियो लाया जा रहा है…",
"pagerFetchingPictures": "और तस्वीरें लाई जा रही हैं…",
- "pagerCaughtUp": "अभी के लिए बस इतना ही"
+ "pagerCaughtUp": "अभी के लिए बस इतना ही",
+ "schedToggle": "बाद के लिए शेड्यूल करें",
+ "schedAt": "पोस्ट करने का समय",
+ "schedBtn": "शेड्यूल करें",
+ "schedDone": "{when} के लिए शेड्यूल किया गया",
+ "schedTooSoon": "अभी से कम से कम दो मिनट बाद का समय चुनें",
+ "schedListTitle": "शेड्यूल किए गए ({n})",
+ "schedCancelled": "शेड्यूल की गई पोस्ट रद्द हुई"
}
diff --git a/src/locales/id.json b/src/locales/id.json
index 6773d96..e2402eb 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -137,6 +137,10 @@
"giftSkip": "Lewati dulu",
"giftBackedUp": "Saya sudah mencatatnya",
"giftLink": "Hadiahkan Bitcoin",
+ "giftNotePlaceholder": "Tambahkan catatan (opsional)",
+ "giftFromMe": "Tunjukkan ini dari saya (yang mengklaim akan mengikuti Anda)",
+ "giftFrom": "Dari",
+ "giftClaimFollows": "Mengklaimnya akan mengikuti pengirim",
"giftLinkDesc": "Tandatangani sejumlah dana sebagai tautan. Siapa pun yang membukanya mengklaim Bitcoin itu ke dompet baru yang hanya dia kendalikan.",
"giftLinkWarn": "⚠ Siapa pun yang membuka tautan ini bisa mengklaim jumlahnya, jadi bagikan hanya kepada orang yang Anda beri hadiah. Jaga jumlahnya tetap kecil.",
"giftAmountLabel": "Jumlah yang dihadiahkan",
@@ -145,6 +149,7 @@
"giftReserved": "Tautan hadiah belum diklaim ({n})",
"claimBody": "Klaim ke dompet baru yang hanya Anda kendalikan.",
"claimBtn": "Klaim",
+ "claimNowBtn": "Klaim sekarang",
"claimFailed": "Hadiah ini sudah diklaim atau tidak lagi tersedia.",
"claimedTitle": "Diklaim!",
"claimedBody": "Bitcoin Anda sedang dalam perjalanan. Cadangkan frasa pemulihan Anda agar tetap aman.",
@@ -215,6 +220,17 @@
"followHistNone": "Tidak ada salinan lama yang berisi orang yang hilang dari daftarmu.",
"followHistNothing": "Semua orang di salinan itu sudah ada di daftarmu.",
"followHistRestored": "{n} ikutan dipulihkan.",
+ "profHistDesc": "Jika aplikasi lain mengganti profil Anda, salinan lama mungkin masih ada di relay yang menyimpan riwayat. Memakainya hanya mengisi kolom yang kosong di sini.",
+ "profHistLook": "Cari salinan lama",
+ "profHistUse": "Pakai",
+ "profHistNoName": "(tanpa nama)",
+ "profHistNone": "Tidak ada salinan lama yang punya sesuatu yang kurang di sini.",
+ "profHistNothing": "Semua isi salinan itu sudah ada di sini.",
+ "profHistFilled": "{n} kolom diisi dari salinan lama. Simpan untuk menerbitkan.",
+ "profHist_name": "nama",
+ "profHist_about": "bio",
+ "profHist_picture": "foto",
+ "profHist_banner": "spanduk",
"securityTitle": "Keamanan",
"arkCoinsNone": "Belum ada koin di Belanja.",
"profKeyLink": "Kunci publik",
@@ -247,6 +263,8 @@
"mediaNoImages": "Belum ada gambar di feed ini.",
"mediaNoVideos": "Belum ada video vertikal di feed ini.",
"videoTapToPlay": "Ketuk untuk memutar",
+ "videoPlay": "Putar",
+ "videoPause": "Jeda",
"videoLargeTitle": "Video",
"videoLargeToggle": "Putar otomatis video besar",
"videoLargeHelp": "Video di atas {mb} MB atau lebih dari 1080p menunggu ketukan, agar feed tidak menghabiskan kuota. Batasnya lebih rendah di koneksi lambat atau mode hemat data.",
@@ -261,5 +279,15 @@
"arkRenewViewTx": "Lihat transaksi ronde",
"pagerFetchingVideo": "Mengambil video berikutnya…",
"pagerFetchingPictures": "Mengambil gambar lainnya…",
- "pagerCaughtUp": "Sementara itu saja"
+ "pagerCaughtUp": "Sementara itu saja",
+ "onbGiftNameTitle": "Sekarang pilih nama pengguna",
+ "onbGiftNameBody": "Klaim sebuah nama dan dapatkan alamat pembayaran sendiri, agar orang bisa mengirimimu bitcoin hanya dengan namamu. Lalu pilih foto dan sampul.",
+ "namesChangeTo": "Ganti ke {addr}",
+ "schedToggle": "Jadwalkan untuk nanti",
+ "schedAt": "Posting pada",
+ "schedBtn": "Jadwalkan",
+ "schedDone": "Dijadwalkan untuk {when}",
+ "schedTooSoon": "Pilih waktu setidaknya dua menit dari sekarang",
+ "schedListTitle": "Terjadwal ({n})",
+ "schedCancelled": "Postingan terjadwal dibatalkan"
}
diff --git a/src/locales/it.json b/src/locales/it.json
index 7ac8b00..a9998c1 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -96,6 +96,17 @@
"relaysReload": "Ricarica",
"followHistNothing": "Tutti quelli presenti in quella copia sono già nella tua lista.",
"followHistRestored": "Ripristinati {n} seguiti.",
+ "profHistDesc": "Se un’altra app ha sostituito il tuo profilo, una copia più vecchia potrebbe essere ancora su un relay che conserva la cronologia. Usarne una riempie solo i campi qui vuoti.",
+ "profHistLook": "Cerca copie più vecchie",
+ "profHistUse": "Usa",
+ "profHistNoName": "(senza nome)",
+ "profHistNone": "Nessuna copia più vecchia ha qualcosa che manca qui.",
+ "profHistNothing": "Tutto ciò che c’è in quella copia è già qui.",
+ "profHistFilled": "{n} campi riempiti dalla copia più vecchia. Salva per pubblicare.",
+ "profHist_name": "nome",
+ "profHist_about": "bio",
+ "profHist_picture": "foto",
+ "profHist_banner": "banner",
"profFollowing": "Seguiti",
"profFollowers": "Follower",
"profFollowsNone": "Non segui ancora nessuno.",
@@ -222,6 +233,8 @@
"playVideo": "Riproduci",
"videoUnmute": "Attiva audio",
"videoTapToPlay": "Tocca per riprodurre",
+ "videoPlay": "Riproduci",
+ "videoPause": "Pausa",
"videoNoPicture": "Questo browser non riesce a mostrare l'immagine di questo video",
"videoOpenFile": "Apri il file",
"videoLargeTitle": "Video",
@@ -488,8 +501,8 @@
"addrRead": "Lettura di {min} min",
"addrList": "Una lista di {name}",
"msgSignerOff": "Il tuo signer nostr non è connesso, quindi non puoi ancora pubblicare a tuo nome.",
- "msgAmberDms": "Hai effettuato l’accesso con Amber: i messaggi inviati a te non possono essere aperti qui, solo quelli che hai inviato da questo dispositivo.",
- "msgAmberConnect": "Connetti Amber",
+ "msgAmberDms": "Hai effettuato l’accesso con un’app di firma: i messaggi inviati a te non possono essere aperti qui, solo quelli inviati da questo dispositivo.",
+ "msgAmberConnect": "Connetti il firmatario",
"postRepostedBy": "{name} ha ripubblicato",
"gifBtnTitle": "Aggiungi una GIF",
"gifSearchPh": "Cerca GIF",
@@ -825,6 +838,10 @@
"giftWelcome": "Ti hanno inviato dei Bitcoin",
"giftBackedUp": "L'ho scritta",
"giftLink": "Regala dei Bitcoin",
+ "giftNotePlaceholder": "Aggiungi un messaggio (facoltativo)",
+ "giftFromMe": "Di’ che è da parte mia (chi lo riscatta ti segue)",
+ "giftFrom": "Da",
+ "giftClaimFollows": "Riscattandolo seguirai questa persona",
"giftLinkDesc": "Prefirma un importo sotto forma di link. Chi lo apre riscuote i Bitcoin in un portafoglio nuovo di zecca che controlla solo lui.",
"giftLinkWarn": "⚠ Chiunque apra questo link può riscuotere l'importo, quindi condividilo solo con la persona a cui fai il regalo. Mantieni importi piccoli.",
"giftLinkReveal": "Crea link regalo",
@@ -888,6 +905,7 @@
"claimOr": "oppure",
"goToWallet": "Vai al portafoglio",
"claimBtn": "Riscuoti",
+ "claimNowBtn": "Riscatta ora",
"claimFeeNote": "~{n} di commissione",
"claimFailed": "Questo regalo è già stato riscosso o non è più disponibile.",
"claimNotVisibleBody": "Le monete che finanziano questo regalo non sono visibili dalla rete/fonte dati di questo portafoglio. Assicurati che questo portafoglio usi la stessa rete e fonte dati del mittente, poi riprova.",
@@ -1154,6 +1172,8 @@
"onbSpendBody": "Scegli un nome utente e ottieni il tuo indirizzo di pagamento. Chiunque potrà inviarti bitcoin all’istante, solo con il tuo nome.",
"onbNameWait": "Preparazione in corso…",
"onbNotNow": "Non ora",
+ "onbGiftNameTitle": "Ora scegli un nome utente",
+ "onbGiftNameBody": "Prendi un nome e ottieni il tuo indirizzo di pagamento, così ti possono inviare bitcoin solo con il tuo nome. Poi scegli una foto e una copertina.",
"onbAvatarTitle": "Scegli il tuo aspetto",
"onbAvatarBody": "Scegli una copertina e un avatar, oppure carica i tuoi. Puoi cambiarli quando vuoi.",
"onbCover": "Immagine di copertina",
@@ -1177,7 +1197,7 @@
"nlSignInNostr": "Accedi con Nostr",
"nlDesc": "Usa il tuo account Nostr per aprire il portafoglio, su qualsiasi dispositivo, con lo stesso account.",
"nlExtension": "Usa l’estensione del browser",
- "nlAmber": "Accedi con Amber (Android)",
+ "nlAmber": "Accedi con un’app di firma (Android)",
"nlAmberWait": "In attesa dell’app di firma… approva lì, poi torna qui.",
"nlAmberPasteHint": "La risposta viene letta dagli appunti al tuo ritorno. Se non succede nulla, incollala qui:",
"nlAmberPastePh": "Incolla la risposta del firmatario",
@@ -1187,7 +1207,7 @@
"nlPasskey": "Accedi con una passkey",
"nlKeyOrBunker": "nsec, chiave privata o bunker://…",
"nlGo": "Accedi",
- "nlHint": "Funziona con Amber, Amethyst e altri firmatari remoti (incolla il loro indirizzo bunker://), o con un’estensione del browser. Una chiave incollata viene usata una sola volta e mai salvata.",
+ "nlHint": "Funziona con le app di firma remota (incolla il loro indirizzo bunker://) o con un’estensione del browser. Una chiave incollata viene usata una volta e mai salvata.",
"nlApprove": "Approva la richiesta nel tuo firmatario →",
"nlUnrecognized": "Non sembra una chiave privata né un indirizzo bunker://.",
"nlNewTitle": "Crea un portafoglio per questo account",
@@ -1208,7 +1228,7 @@
"nlReconnectDesc": "Il tuo signer non è connesso, quindi questa app non può firmare per te. Ricollegalo per pubblicare e aggiornare il profilo. Il portafoglio e il saldo non ne risentono.",
"nlReconnectTitle": "Ricollega il signer",
"nlAmberUpgradeTitle": "Leggi i tuoi messaggi",
- "nlAmberUpgradeBody": "Amber può aprire i messaggi qui solo se è connesso come signer remoto. Tocca qui sotto, approva in Amber (consenti la decifratura) e i messaggi verranno caricati. La firma funziona come prima.",
+ "nlAmberUpgradeBody": "La tua app di firma può aprire i messaggi qui solo se è connessa come firmatario remoto. Tocca qui sotto, approva nell’app di firma (consenti la decifratura) e i messaggi verranno caricati. La firma funziona come prima.",
"nlReconnectBody": "Il tuo signer Nostr non è connesso su questo dispositivo. Ricollegalo per continuare ad agire come {npub}…",
"nlWrongAccount": "Quel signer è di un altro account Nostr. Ricollega {npub}.",
"nlLinked": "Portafoglio collegato al tuo account Nostr",
@@ -1240,6 +1260,7 @@
"namesClaim": "Richiedi",
"namesClaimed": "Ora sei {name}",
"namesCopy": "Copia indirizzo",
+ "namesChangeTo": "Cambia in {addr}",
"namesMoreOpen": "Altre opzioni",
"namesMoreTitle": "Altri modi per farti pagare",
"namesMoreBolt11": "Fattura Lightning (BOLT 11)",
@@ -1458,5 +1479,12 @@
"lockPwOrPassword": "oppure imposta una password:",
"pagerFetchingVideo": "Caricamento del prossimo video…",
"pagerFetchingPictures": "Caricamento di altre immagini…",
- "pagerCaughtUp": "Per ora è tutto"
+ "pagerCaughtUp": "Per ora è tutto",
+ "schedToggle": "Programma per dopo",
+ "schedAt": "Pubblica il",
+ "schedBtn": "Programma",
+ "schedDone": "Programmato per {when}",
+ "schedTooSoon": "Scegli un orario almeno due minuti da adesso",
+ "schedListTitle": "Programmati ({n})",
+ "schedCancelled": "Post programmato annullato"
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index b8968cc..27970db 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -364,6 +364,10 @@
"giftWelcome": "Bitcoin が届きました",
"giftBackedUp": "書き留めました",
"giftLink": "Bitcoin を贈る",
+ "giftNotePlaceholder": "メッセージを添える(任意)",
+ "giftFromMe": "自分からだと伝える(受け取った人があなたをフォローします)",
+ "giftFrom": "送り主",
+ "giftClaimFollows": "受け取ると送り主をフォローします",
"giftLinkDesc": "金額をリンクとして事前署名します。リンクを開いた人が、その人だけが管理できる真新しいウォレットに Bitcoin を受け取ります。",
"giftLinkWarn": "⚠ このリンクを開いた人は誰でもその金額を受け取れるので、贈る相手とだけ共有してください。金額は少なめにしましょう。",
"giftLinkReveal": "ギフトリンクを作成",
@@ -425,6 +429,7 @@
"claimOr": "または",
"goToWallet": "ウォレットに行く",
"claimBtn": "受け取る",
+ "claimNowBtn": "今すぐ受け取る",
"claimFeeNote": "ふりがな{n} 料金について",
"claimFailed": "このギフトはすでに受け取られたか、現在利用できません。",
"claimNotVisibleBody": "このギフトに資金を調達するコインは、このウォレットのネットワーク/データソースから表示されません。 このウォレットは、送信者と同じネットワークとデータソースを使用していて、もう一度お試しください。",
@@ -618,6 +623,8 @@
"onbSpendBody": "ユーザー名を主張し、独自の支払いアドレスを取得する, 人々は、あなたの名前だけで、即座にビットコインを送ることができます。",
"onbNameWait": "物事を準備する...",
"onbNotNow": "今は今",
+ "onbGiftNameTitle": "次にユーザー名を選びましょう",
+ "onbGiftNameBody": "名前を取得して自分の支払いアドレスを手に入れましょう。名前だけでビットコインを受け取れます。そのあと写真とカバーを選びます。",
"onbAvatarTitle": "お問い合わせ",
"onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。",
"onbUpload": "写真をアップロードする",
@@ -640,7 +647,7 @@
"nlPasskey": "パスキーでサインイン",
"nlKeyOrBunker": "nsec, プライベートキー, またはバンカー://...",
"nlGo": "ログイン",
- "nlHint": "Amber、Amethyst、その他のリモートサイダー(Bunker://アドレスを貼り付ける)、またはブラウザ拡張で動作します。 過去のキーは一度使用され、貯えられません。",
+ "nlHint": "リモート署名アプリ(bunker:// アドレスを貼り付け)またはブラウザ拡張機能で使えます。貼り付けた鍵は一度だけ使われ、保存されません。",
"nlApprove": "申請書の承認 →",
"nlUnrecognized": "つまり、秘密鍵や2段のアドレスは見えません。",
"nlNewTitle": "このアカウントのウォレットを作成する",
@@ -678,6 +685,7 @@
"namesClaim": "ログイン",
"namesClaimed": "これから {name}",
"namesCopy": "コピーアドレス",
+ "namesChangeTo": "{addr} に変更",
"namesMoreOpen": "その他のオプション",
"namesMoreTitle": "支払われるより多くの方法",
"namesMoreBolt11": "ライトニングインボイス(BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "あなたのリストに欠けている人を含む古いコピーはありません。",
"followHistNothing": "そのコピーの全員がすでにリストにいます。",
"followHistRestored": "{n}人のフォローを復元しました。",
+ "profHistDesc": "別のアプリがプロフィールを置き換えた場合、履歴を保持するリレーに古いコピーが残っていることがあります。使うと、ここで空欄の項目だけが埋まります。",
+ "profHistLook": "古いコピーを探す",
+ "profHistUse": "使う",
+ "profHistNoName": "(名前なし)",
+ "profHistNone": "ここに足りないものを持つ古いコピーはありません。",
+ "profHistNothing": "そのコピーの内容はすべてここにあります。",
+ "profHistFilled": "古いコピーから{n}項目を埋めました。公開するには保存してください。",
+ "profHist_name": "名前",
+ "profHist_about": "自己紹介",
+ "profHist_picture": "写真",
+ "profHist_banner": "バナー",
"securityTitle": "セキュリティ",
"arkCoinsNone": "支出にはまだコインがありません。",
"profKeyLink": "公開鍵",
@@ -910,6 +929,8 @@
"mediaNoImages": "このフィードにはまだ画像がありません。",
"mediaNoVideos": "このフィードにはまだ縦型動画がありません。",
"videoTapToPlay": "タップして再生",
+ "videoPlay": "再生",
+ "videoPause": "一時停止",
"videoLargeTitle": "動画",
"videoLargeToggle": "大きな動画を自動再生",
"videoLargeHelp": "{mb} MBを超える動画や1080pを超える動画は、データを使いすぎないようタップするまで再生しません。低速回線やデータセーバー時は上限が下がります。",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "ラウンドのトランザクションを見る",
"pagerFetchingVideo": "次の動画を取得中…",
"pagerFetchingPictures": "画像をさらに取得中…",
- "pagerCaughtUp": "今のところ以上です"
+ "pagerCaughtUp": "今のところ以上です",
+ "schedToggle": "あとで投稿する",
+ "schedAt": "投稿日時",
+ "schedBtn": "予約する",
+ "schedDone": "{when} に予約しました",
+ "schedTooSoon": "今から2分以上先の時刻を選んでください",
+ "schedListTitle": "予約済み({n})",
+ "schedCancelled": "予約投稿を取り消しました"
}
diff --git a/src/locales/ko.json b/src/locales/ko.json
index e4bf342..94f9f0e 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -363,6 +363,10 @@
"giftWelcome": "Bitcoin을 받았습니다",
"giftBackedUp": "적어 두었습니다",
"giftLink": "Bitcoin 선물하기",
+ "giftNotePlaceholder": "메시지 추가 (선택)",
+ "giftFromMe": "내가 보냈다고 알리기 (받는 사람이 나를 팔로우함)",
+ "giftFrom": "보낸 사람",
+ "giftClaimFollows": "받으면 보낸 사람을 팔로우합니다",
"giftLinkDesc": "금액을 링크로 미리 서명하세요. 링크를 여는 사람은 그 Bitcoin을 자신만 통제하는 완전히 새로운 지갑으로 받게 됩니다.",
"giftLinkWarn": "⚠ 이 링크를 여는 사람은 누구나 금액을 받을 수 있으니, 선물할 상대에게만 공유하세요. 금액은 적게 유지하세요.",
"giftLinkReveal": "선물 링크 만들기",
@@ -424,6 +428,7 @@
"claimOr": "또는",
"goToWallet": "지갑 바로가기",
"claimBtn": "받기",
+ "claimNowBtn": "지금 받기",
"claimFeeNote": "·{n} 요금안내",
"claimFailed": "이 선물은 이미 수령되었거나 더 이상 이용할 수 없습니다.",
"claimNotVisibleBody": "이 선물은 이 지갑의 네트워크/데이터 소스에서 볼 수 없습니다. 이 지갑이 같은 네트워크와 데이터 소스를 보낸 후 다시 시도하십시오.",
@@ -617,6 +622,8 @@
"onbSpendBody": "사용자 이름 및 자신의 지불 주소를 얻기, 사람들은 즉시 Bitcoin을 보낼 수 있습니다, 그냥 당신의 이름.",
"onbNameWait": "준비하는 것들 ...",
"onbNotNow": "현재 위치",
+ "onbGiftNameTitle": "이제 사용자 이름을 고르세요",
+ "onbGiftNameBody": "이름을 등록하고 나만의 결제 주소를 받으세요. 이름만으로 비트코인을 받을 수 있어요. 그다음 사진과 커버를 고르세요.",
"onbAvatarTitle": "자주 묻는 질문",
"onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.",
"onbUpload": "사진 업로드",
@@ -639,7 +646,7 @@
"nlPasskey": "패스키로 로그인",
"nlKeyOrBunker": "nsec, 개인 키, 또는 벙커://...",
"nlGo": "로그인",
- "nlHint": "Amber, Amethyst 및 기타 원격 Signers와 함께 작동합니다. (이층 http:// 주소) 또는 브라우저 확장. 과거의 열쇠는 한 번 이용되고 결코 저장되지 않습니다.",
+ "nlHint": "원격 서명 앱(bunker:// 주소를 붙여넣기) 또는 브라우저 확장 프로그램과 함께 작동합니다. 붙여넣은 키는 한 번만 사용되며 저장되지 않습니다.",
"nlApprove": "당신의 signer에 있는 요구에 찬성하십시오 →",
"nlUnrecognized": "그것은 개인 키 또는 벙커처럼 보이지 않습니다.:// 주소.",
"nlNewTitle": "이 계정의 지갑 만들기",
@@ -677,6 +684,7 @@
"namesClaim": "계정 만들기",
"namesClaimed": "현재 위치 {name}",
"namesCopy": "본문 바로가기",
+ "namesChangeTo": "{addr}(으)로 변경",
"namesMoreOpen": "더 많은 옵션",
"namesMoreTitle": "지불하는 방법",
"namesMoreBolt11": "번개 송장 (BOLT 11)",
@@ -877,6 +885,17 @@
"followHistNone": "목록에 빠진 사람이 들어 있는 예전 사본이 없습니다.",
"followHistNothing": "그 사본의 모든 사람이 이미 목록에 있습니다.",
"followHistRestored": "팔로우 {n}명을 복원했습니다.",
+ "profHistDesc": "다른 앱이 프로필을 덮어썼다면, 기록을 보관하는 릴레이에 이전 사본이 남아 있을 수 있습니다. 사용하면 여기서 비어 있는 항목만 채웁니다.",
+ "profHistLook": "이전 사본 찾기",
+ "profHistUse": "사용",
+ "profHistNoName": "(이름 없음)",
+ "profHistNone": "여기에 없는 내용을 가진 이전 사본이 없습니다.",
+ "profHistNothing": "그 사본의 내용은 모두 이미 여기에 있습니다.",
+ "profHistFilled": "이전 사본에서 {n}개 항목을 채웠습니다. 게시하려면 저장하세요.",
+ "profHist_name": "이름",
+ "profHist_about": "소개",
+ "profHist_picture": "사진",
+ "profHist_banner": "배너",
"securityTitle": "보안",
"arkCoinsNone": "지출에 아직 코인이 없습니다.",
"profKeyLink": "공개 키",
@@ -909,6 +928,8 @@
"mediaNoImages": "이 피드에는 아직 이미지가 없습니다.",
"mediaNoVideos": "이 피드에는 아직 세로 동영상이 없습니다.",
"videoTapToPlay": "탭하여 재생",
+ "videoPlay": "재생",
+ "videoPause": "일시정지",
"videoLargeTitle": "동영상",
"videoLargeToggle": "큰 동영상 자동 재생",
"videoLargeHelp": "{mb}MB를 넘거나 1080p보다 높은 동영상은 데이터를 아끼기 위해 탭할 때까지 기다립니다. 느린 연결이나 데이터 절약 모드에서는 한도가 더 낮습니다.",
@@ -923,5 +944,12 @@
"arkRenewViewTx": "라운드 거래 보기",
"pagerFetchingVideo": "다음 동영상을 가져오는 중…",
"pagerFetchingPictures": "사진을 더 가져오는 중…",
- "pagerCaughtUp": "지금은 여기까지예요"
+ "pagerCaughtUp": "지금은 여기까지예요",
+ "schedToggle": "나중에 게시 예약",
+ "schedAt": "게시 시간",
+ "schedBtn": "예약",
+ "schedDone": "{when}에 예약됨",
+ "schedTooSoon": "지금부터 최소 2분 뒤의 시간을 고르세요",
+ "schedListTitle": "예약됨 ({n})",
+ "schedCancelled": "예약 게시물이 취소되었습니다"
}
diff --git a/src/locales/mr.json b/src/locales/mr.json
index 17ee1d5..a7ad2b4 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -137,6 +137,10 @@
"giftSkip": "आत्ता वगळा",
"giftBackedUp": "मी ते लिहून ठेवले आहे",
"giftLink": "थोडे Bitcoin भेट द्या",
+ "giftNotePlaceholder": "एक नोंद जोडा (पर्यायी)",
+ "giftFromMe": "हे माझ्याकडून आहे असे सांगा (दावा करणारे तुम्हाला फॉलो करतील)",
+ "giftFrom": "पाठवणारे",
+ "giftClaimFollows": "दावा केल्यावर तुम्ही त्यांना फॉलो कराल",
"giftLinkDesc": "एखादी रक्कम लिंक म्हणून आधीच स्वाक्षरित करा. ती उघडणारा कोणीही ते Bitcoin फक्त त्याच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घेतो.",
"giftLinkWarn": "⚠ ही लिंक उघडणारा कोणीही ती रक्कम घेऊ शकतो, म्हणून ज्याला भेट देत आहात फक्त त्याच्यासोबतच ती शेअर करा. रक्कम कमी ठेवा.",
"giftAmountLabel": "भेट देण्याची रक्कम",
@@ -145,6 +149,7 @@
"giftReserved": "दावा न केलेल्या भेट लिंक ({n})",
"claimBody": "ते फक्त तुमच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घ्या.",
"claimBtn": "ते घ्या",
+ "claimNowBtn": "आता घ्या",
"claimFailed": "ही भेट आधीच घेतली गेली आहे किंवा आता उपलब्ध नाही.",
"claimedTitle": "घेतले!",
"claimedBody": "तुमचे Bitcoin येत आहे. ते सुरक्षित ठेवण्यासाठी तुमच्या रिकव्हरी वाक्यांशाचा बॅकअप घ्या.",
@@ -215,6 +220,17 @@
"followHistNone": "तुमच्या यादीत नसलेले कोणी असलेली जुनी प्रत नाही.",
"followHistNothing": "त्या प्रतीतील सर्वजण आधीच तुमच्या यादीत आहेत.",
"followHistRestored": "{n} फॉलो पुनर्संचयित केले.",
+ "profHistDesc": "दुसऱ्या अ‍ॅपने तुमची प्रोफाइल बदलली असेल, तर इतिहास ठेवणाऱ्या रिलेवर जुनी प्रत अजूनही असू शकते. ती वापरल्यास येथे फक्त रिकामी फील्ड भरली जातात.",
+ "profHistLook": "जुन्या प्रती शोधा",
+ "profHistUse": "वापरा",
+ "profHistNoName": "(नाव नाही)",
+ "profHistNone": "कोणत्याही जुन्या प्रतीत येथे नसलेले काही नाही.",
+ "profHistNothing": "त्या प्रतीतील सर्व काही येथे आधीच आहे.",
+ "profHistFilled": "जुन्या प्रतीतून {n} फील्ड भरली. प्रकाशित करण्यासाठी जतन करा.",
+ "profHist_name": "नाव",
+ "profHist_about": "परिचय",
+ "profHist_picture": "फोटो",
+ "profHist_banner": "बॅनर",
"securityTitle": "सुरक्षा",
"arkCoinsNone": "स्पेंडिंगमध्ये अद्याप कोणतेही कॉइन नाहीत.",
"profKeyLink": "सार्वजनिक की",
@@ -247,6 +263,8 @@
"mediaNoImages": "या फीडमध्ये अजून चित्रे नाहीत.",
"mediaNoVideos": "या फीडमध्ये अजून उभे व्हिडिओ नाहीत.",
"videoTapToPlay": "चालवण्यासाठी टॅप करा",
+ "videoPlay": "चालवा",
+ "videoPause": "थांबवा",
"videoLargeTitle": "व्हिडिओ",
"videoLargeToggle": "मोठे व्हिडिओ आपोआप चालवा",
"videoLargeHelp": "{mb} MB पेक्षा मोठे किंवा 1080p पेक्षा वरचे व्हिडिओ तुमचा डेटा वाचवण्यासाठी टॅपची वाट पाहतात. धीम्या किंवा डेटा-सेव्हर कनेक्शनवर मर्यादा कमी असते.",
@@ -261,5 +279,15 @@
"arkRenewViewTx": "राउंडचा व्यवहार पहा",
"pagerFetchingVideo": "पुढचा व्हिडिओ आणत आहे…",
"pagerFetchingPictures": "आणखी चित्रे आणत आहे…",
- "pagerCaughtUp": "सध्या एवढेच"
+ "pagerCaughtUp": "सध्या एवढेच",
+ "onbGiftNameTitle": "आता एक युजरनेम निवडा",
+ "onbGiftNameBody": "एक नाव घ्या आणि स्वतःचा पेमेंट पत्ता मिळवा, म्हणजे लोक फक्त तुमच्या नावाने तुम्हाला बिटकॉइन पाठवू शकतील. मग एक फोटो आणि कव्हर निवडा.",
+ "namesChangeTo": "{addr} मध्ये बदला",
+ "schedToggle": "नंतरसाठी वेळ ठरवा",
+ "schedAt": "पोस्टची वेळ",
+ "schedBtn": "वेळ ठरवा",
+ "schedDone": "{when} साठी ठरवले",
+ "schedTooSoon": "आतापासून किमान दोन मिनिटांनंतरची वेळ निवडा",
+ "schedListTitle": "ठरवलेल्या ({n})",
+ "schedCancelled": "ठरवलेली पोस्ट रद्द झाली"
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 11ff22c..56e8cda 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -364,6 +364,10 @@
"giftWelcome": "Você recebeu Bitcoin",
"giftBackedUp": "Já anotei",
"giftLink": "Presentear Bitcoin",
+ "giftNotePlaceholder": "Adicione um recado (opcional)",
+ "giftFromMe": "Dizer que é meu (quem resgatar vai te seguir)",
+ "giftFrom": "De",
+ "giftClaimFollows": "Ao resgatar, você passa a seguir essa pessoa",
"giftLinkDesc": "Pré-assine um valor como um link. Quem o abrir resgata os Bitcoin para uma carteira novinha que só essa pessoa controla.",
"giftLinkWarn": "⚠ Qualquer pessoa que abrir este link pode resgatar o valor, então compartilhe-o apenas com a pessoa que você está presenteando. Mantenha os valores baixos.",
"giftLinkReveal": "Criar link de presente",
@@ -425,6 +429,7 @@
"claimOr": "ou",
"goToWallet": "Ir para a carteira",
"claimBtn": "Resgatar",
+ "claimNowBtn": "Resgatar agora",
"claimFeeNote": "~{n} taxa",
"claimFailed": "Este presente já foi resgatado ou não está mais disponível.",
"claimNotVisibleBody": "As moedas que financiam este presente não são visíveis da rede/fonte de dados desta carteira. Certifique-se de que esta carteira usa a mesma rede e fonte de dados que o remetente e tente novamente.",
@@ -618,6 +623,8 @@
"onbSpendBody": "Reclame um nome de usuário e obtenha seu próprio endereço de pagamento, as pessoas podem enviar bitcoin instantaneamente, com apenas seu nome.",
"onbNameWait": "Preparando as coisas...",
"onbNotNow": "Agora não.",
+ "onbGiftNameTitle": "Agora escolha um nome de usuário",
+ "onbGiftNameBody": "Reserve um nome e ganhe seu próprio endereço de pagamento, para que possam te enviar bitcoin só com o seu nome. Depois escolha uma foto e uma capa.",
"onbAvatarTitle": "Escolha o seu look",
"onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.",
"onbUpload": "Enviar uma foto",
@@ -640,7 +647,7 @@
"nlPasskey": "Entrar com uma chave-mestra",
"nlKeyOrBunker": "Nsec, chave privada, ou bunker://...",
"nlGo": "Entrar",
- "nlHint": "Funciona com Amber, Amethyst e outros sinalizadores remotos (colar seu bunker://endereço), ou uma extensão do navegador. Uma chave colada é usada uma vez e nunca armazenada.",
+ "nlHint": "Funciona com apps de assinatura remota (cole o endereço bunker:// delas) ou uma extensão do navegador. Uma chave colada é usada uma vez e nunca guardada.",
"nlApprove": "Aprovar o pedido em seu assinante →",
"nlUnrecognized": "Isso não se parece com uma chave privada ou um endereço bunker://.",
"nlNewTitle": "Criar uma carteira para esta conta",
@@ -678,6 +685,7 @@
"namesClaim": "Alegação",
"namesClaimed": "Agora és. {name}",
"namesCopy": "Copiar endereço",
+ "namesChangeTo": "Mudar para {addr}",
"namesMoreOpen": "Mais opções",
"namesMoreTitle": "Mais formas de ser pago",
"namesMoreBolt11": "Factura relâmpago (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "Nenhuma cópia antiga tem alguém que falte na sua lista.",
"followHistNothing": "Todos dessa cópia já estão na sua lista.",
"followHistRestored": "{n} seguidos restaurados.",
+ "profHistDesc": "Se outro app substituiu seu perfil, uma cópia mais antiga ainda pode estar em um relay que guarda histórico. Usá-la preenche apenas os campos que estão vazios aqui.",
+ "profHistLook": "Procurar cópias mais antigas",
+ "profHistUse": "Usar",
+ "profHistNoName": "(sem nome)",
+ "profHistNone": "Nenhuma cópia mais antiga tem algo que falte aqui.",
+ "profHistNothing": "Tudo dessa cópia já está aqui.",
+ "profHistFilled": "{n} campos preenchidos da cópia mais antiga. Salve para publicar.",
+ "profHist_name": "nome",
+ "profHist_about": "bio",
+ "profHist_picture": "foto",
+ "profHist_banner": "capa",
"securityTitle": "Segurança",
"arkCoinsNone": "Ainda não há moedas em Gastos.",
"profKeyLink": "Chave pública",
@@ -910,6 +929,8 @@
"mediaNoImages": "Ainda não há imagens neste feed.",
"mediaNoVideos": "Ainda não há vídeos verticais neste feed.",
"videoTapToPlay": "Toque para reproduzir",
+ "videoPlay": "Reproduzir",
+ "videoPause": "Pausar",
"videoLargeTitle": "Vídeos",
"videoLargeToggle": "Reproduzir vídeos grandes automaticamente",
"videoLargeHelp": "Vídeos acima de {mb} MB ou de 1080p esperam um toque, para o feed não consumir seus dados. O limite é menor em conexões lentas ou com economia de dados.",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "Ver a transação da rodada",
"pagerFetchingVideo": "Buscando o próximo vídeo…",
"pagerFetchingPictures": "Buscando mais imagens…",
- "pagerCaughtUp": "Por enquanto é só isso"
+ "pagerCaughtUp": "Por enquanto é só isso",
+ "schedToggle": "Agendar para depois",
+ "schedAt": "Publicar em",
+ "schedBtn": "Agendar",
+ "schedDone": "Agendado para {when}",
+ "schedTooSoon": "Escolha um horário pelo menos dois minutos a partir de agora",
+ "schedListTitle": "Agendados ({n})",
+ "schedCancelled": "Publicação agendada cancelada"
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 0f2936d..7ec23b3 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -364,6 +364,10 @@
"giftWelcome": "Вам отправили Bitcoin",
"giftBackedUp": "Я записал(а)",
"giftLink": "Подарить немного Bitcoin",
+ "giftNotePlaceholder": "Добавьте записку (необязательно)",
+ "giftFromMe": "Указать, что это от меня (получатель подпишется на вас)",
+ "giftFrom": "От",
+ "giftClaimFollows": "Получив подарок, вы подпишетесь на отправителя",
"giftLinkDesc": "Заранее подпишите сумму в виде ссылки. Тот, кто откроет её, получит Bitcoin в совершенно новый кошелёк, которым управляет только он.",
"giftLinkWarn": "⚠ Любой, кто откроет эту ссылку, может получить эту сумму, поэтому делитесь ею только с тем, кому дарите. Держите суммы небольшими.",
"giftLinkReveal": "Создать ссылку-подарок",
@@ -425,6 +429,7 @@
"claimOr": "или",
"goToWallet": "Иди к кошельку",
"claimBtn": "Получить",
+ "claimNowBtn": "Получить сейчас",
"claimFeeNote": "~{n} взнос",
"claimFailed": "Этот подарок уже получен или больше недоступен.",
"claimNotVisibleBody": "Монеты, финансирующие этот подарок, не видны из сети / источника данных этого кошелька. Убедитесь, что этот кошелек использует ту же сеть и источник данных, что и отправитель.",
@@ -618,6 +623,8 @@
"onbSpendBody": "Заявите имя пользователя и получите свой собственный платежный адрес, люди могут отправить вам биткойн мгновенно, только с вашим именем.",
"onbNameWait": "Готовить вещи...",
"onbNotNow": "Не сейчас",
+ "onbGiftNameTitle": "Теперь выберите имя пользователя",
+ "onbGiftNameBody": "Займите имя и получите свой платёжный адрес — тогда вам смогут отправлять биткоин просто по имени. Потом выберите фото и обложку.",
"onbAvatarTitle": "Выберите свой взгляд",
"onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.",
"onbUpload": "Загрузить фото",
@@ -640,7 +647,7 @@
"nlPasskey": "Зарегистрируйтесь с Passkey",
"nlKeyOrBunker": "nsec, закрытый ключ или бункер:",
"nlGo": "Входите.",
- "nlHint": "Работает с Amber, Amethyst и другими удаленными подписчиками (вставьте их бункер://адрес) или расширение браузера. Вставленный ключ используется один раз и никогда не хранится.",
+ "nlHint": "Работает с приложениями удалённой подписи (вставьте их адрес bunker://) или с расширением браузера. Вставленный ключ используется один раз и никогда не сохраняется.",
"nlApprove": "Утвердить запрос в вашем подписчике",
"nlUnrecognized": "Это не похоже на закрытый ключ или бункер:",
"nlNewTitle": "Создайте кошелек для этого аккаунта",
@@ -678,6 +685,7 @@
"namesClaim": "Претензия",
"namesClaimed": "Ты теперь {name}",
"namesCopy": "Адрес копии",
+ "namesChangeTo": "Сменить на {addr}",
"namesMoreOpen": "Больше вариантов",
"namesMoreTitle": "Больше способов получить оплату",
"namesMoreBolt11": "Инвойс молнии (BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "Нет старых копий с людьми, которых не хватает в вашем списке.",
"followHistNothing": "Все из этой копии уже есть в вашем списке.",
"followHistRestored": "Восстановлено подписок: {n}.",
+ "profHistDesc": "Если другое приложение заменило ваш профиль, более старая копия может ещё храниться на релее с историей. Её использование заполняет только пустые здесь поля.",
+ "profHistLook": "Поискать старые копии",
+ "profHistUse": "Использовать",
+ "profHistNoName": "(без имени)",
+ "profHistNone": "Ни в одной старой копии нет того, чего здесь не хватает.",
+ "profHistNothing": "Всё из этой копии уже здесь.",
+ "profHistFilled": "Заполнено полей из старой копии: {n}. Сохраните, чтобы опубликовать.",
+ "profHist_name": "имя",
+ "profHist_about": "о себе",
+ "profHist_picture": "фото",
+ "profHist_banner": "баннер",
"securityTitle": "Безопасность",
"arkCoinsNone": "В расходах пока нет монет.",
"profKeyLink": "Открытый ключ",
@@ -910,6 +929,8 @@
"mediaNoImages": "В этой ленте пока нет фото.",
"mediaNoVideos": "В этой ленте пока нет вертикальных видео.",
"videoTapToPlay": "Нажмите, чтобы воспроизвести",
+ "videoPlay": "Воспроизвести",
+ "videoPause": "Пауза",
"videoLargeTitle": "Видео",
"videoLargeToggle": "Автовоспроизведение больших видео",
"videoLargeHelp": "Видео больше {mb} МБ или выше 1080p ждут нажатия, чтобы лента не съедала трафик. На медленном соединении или в режиме экономии лимит ниже.",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "Посмотреть транзакцию раунда",
"pagerFetchingVideo": "Загружаем следующее видео…",
"pagerFetchingPictures": "Загружаем ещё картинки…",
- "pagerCaughtUp": "Пока это всё"
+ "pagerCaughtUp": "Пока это всё",
+ "schedToggle": "Запланировать",
+ "schedAt": "Опубликовать",
+ "schedBtn": "Запланировать",
+ "schedDone": "Запланировано на {when}",
+ "schedTooSoon": "Выберите время не раньше чем через две минуты",
+ "schedListTitle": "Запланировано ({n})",
+ "schedCancelled": "Запланированный пост отменён"
}
diff --git a/src/locales/sw.json b/src/locales/sw.json
index b130faf..e5aba2e 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -137,6 +137,10 @@
"giftSkip": "Ruka kwa sasa",
"giftBackedUp": "Nimeshakiandika",
"giftLink": "Toa Bitcoin kama zawadi",
+ "giftNotePlaceholder": "Ongeza ujumbe (si lazima)",
+ "giftFromMe": "Sema ni kutoka kwangu (atakayedai atakufuata)",
+ "giftFrom": "Kutoka kwa",
+ "giftClaimFollows": "Ukidai utamfuata",
"giftLinkDesc": "Tia saini kiasi mapema kama kiungo. Yeyote anayekifungua hudai Bitcoin kwenye pochi mpya kabisa anayoidhibiti yeye pekee.",
"giftLinkWarn": "⚠ Yeyote anayefungua kiungo hiki anaweza kudai kiasi hicho, kwa hivyo kishiriki tu na mtu unayempa zawadi. Weka kiasi kidogo.",
"giftAmountLabel": "Kiasi cha kutoa zawadi",
@@ -145,6 +149,7 @@
"giftReserved": "Viungo vya zawadi visivyodaiwa ({n})",
"claimBody": "Idai kwenye pochi mpya kabisa unayoidhibiti wewe pekee.",
"claimBtn": "Idai",
+ "claimNowBtn": "Dai sasa",
"claimFailed": "Zawadi hii tayari imedaiwa au haipatikani tena.",
"claimedTitle": "Imedaiwa!",
"claimedBody": "Bitcoin yako inakuja. Hifadhi kifungu chako cha kurejesha ili kuilinda.",
@@ -215,6 +220,17 @@
"followHistNone": "Hakuna nakala ya zamani yenye mtu anayekosekana kwenye orodha yako.",
"followHistNothing": "Wote kwenye nakala hiyo tayari wamo kwenye orodha yako.",
"followHistRestored": "Umerejesha unaowafuata {n}.",
+ "profHistDesc": "Ikiwa programu nyingine ilibadilisha wasifu wako, nakala ya zamani huenda bado iko kwenye relay inayohifadhi historia. Kuitumia hujaza tu sehemu zilizo tupu hapa.",
+ "profHistLook": "Tafuta nakala za zamani",
+ "profHistUse": "Tumia",
+ "profHistNoName": "(hakuna jina)",
+ "profHistNone": "Hakuna nakala ya zamani iliyo na kitu kinachokosekana hapa.",
+ "profHistNothing": "Kila kitu kwenye nakala hiyo tayari kipo hapa.",
+ "profHistFilled": "Sehemu {n} zimejazwa kutoka nakala ya zamani. Hifadhi ili kuchapisha.",
+ "profHist_name": "jina",
+ "profHist_about": "wasifu",
+ "profHist_picture": "picha",
+ "profHist_banner": "bango",
"securityTitle": "Usalama",
"arkCoinsNone": "Bado hakuna sarafu kwenye Matumizi.",
"profKeyLink": "Ufunguo wa umma",
@@ -247,6 +263,8 @@
"mediaNoImages": "Bado hakuna picha kwenye mlisho huu.",
"mediaNoVideos": "Bado hakuna video wima kwenye mlisho huu.",
"videoTapToPlay": "Gusa ili kucheza",
+ "videoPlay": "Cheza",
+ "videoPause": "Sitisha",
"videoLargeTitle": "Video",
"videoLargeToggle": "Cheza video kubwa zenyewe",
"videoLargeHelp": "Video zaidi ya MB {mb} au juu ya 1080p husubiri uguse, ili mlisho usimalize data yako. Kikomo ni cha chini kwenye muunganisho wa polepole au wa kuokoa data.",
@@ -261,5 +279,15 @@
"arkRenewViewTx": "Tazama muamala wa raundi",
"pagerFetchingVideo": "Inaleta video inayofuata…",
"pagerFetchingPictures": "Inaleta picha zaidi…",
- "pagerCaughtUp": "Hayo ndiyo yote kwa sasa"
+ "pagerCaughtUp": "Hayo ndiyo yote kwa sasa",
+ "onbGiftNameTitle": "Sasa chagua jina la mtumiaji",
+ "onbGiftNameBody": "Chukua jina upate anwani yako ya malipo, ili watu wakutumie bitcoin kwa jina lako tu. Kisha chagua picha na jalada.",
+ "namesChangeTo": "Badilisha kuwa {addr}",
+ "schedToggle": "Panga kwa baadaye",
+ "schedAt": "Chapisha saa",
+ "schedBtn": "Panga",
+ "schedDone": "Imepangwa {when}",
+ "schedTooSoon": "Chagua muda angalau dakika mbili kuanzia sasa",
+ "schedListTitle": "Zilizopangwa ({n})",
+ "schedCancelled": "Chapisho lililopangwa limeghairiwa"
}
diff --git a/src/locales/ta.json b/src/locales/ta.json
index da4e5b0..a15043d 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -137,6 +137,10 @@
"giftSkip": "இப்போதைக்குத் தவிர்",
"giftBackedUp": "நான் எழுதி வைத்துவிட்டேன்",
"giftLink": "கொஞ்சம் Bitcoin பரிசளி",
+ "giftNotePlaceholder": "ஒரு குறிப்பைச் சேர்க்கவும் (விருப்பம்)",
+ "giftFromMe": "இது என்னிடமிருந்து என்று சொல் (பெறுபவர் உங்களைப் பின்தொடர்வார்)",
+ "giftFrom": "அனுப்பியவர்",
+ "giftClaimFollows": "பெற்றால் அவரைப் பின்தொடர்வீர்கள்",
"giftLinkDesc": "ஒரு தொகையை இணைப்பாக முன்கூட்டியே கையொப்பமிடுங்கள். இதைத் திறப்பவர், அவர்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் Bitcoin-ஐ உரிமை கொள்வார்கள்.",
"giftLinkWarn": "⚠ இந்த இணைப்பைத் திறக்கும் எவரும் தொகையை உரிமை கொள்ளலாம், எனவே நீங்கள் பரிசளிக்கும் நபருடன் மட்டுமே இதைப் பகிரவும். தொகையைக் குறைவாக வைத்திருங்கள்.",
"giftAmountLabel": "பரிசளிக்கும் தொகை",
@@ -145,6 +149,7 @@
"giftReserved": "கோரப்படாத பரிசு இணைப்புகள் ({n})",
"claimBody": "நீங்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் இதை உரிமை கொள்ளுங்கள்.",
"claimBtn": "உரிமை கொள்",
+ "claimNowBtn": "இப்போதே பெறுங்கள்",
"claimFailed": "இந்தப் பரிசு ஏற்கனவே உரிமை கொள்ளப்பட்டுவிட்டது அல்லது இனி கிடைக்கவில்லை.",
"claimedTitle": "உரிமை கொள்ளப்பட்டது!",
"claimedBody": "உங்கள் Bitcoin வந்துகொண்டிருக்கிறது. அதைப் பாதுகாப்பாக வைக்க உங்கள் மீட்பு சொற்றொடரைக் காப்புப்பிரதி எடுங்கள்.",
@@ -215,6 +220,17 @@
"followHistNone": "உங்கள் பட்டியலில் இல்லாதவர் உள்ள பழைய நகல் இல்லை.",
"followHistNothing": "அந்த நகலில் உள்ள அனைவரும் ஏற்கனவே உங்கள் பட்டியலில் உள்ளனர்.",
"followHistRestored": "{n} பின்தொடர்வுகள் மீட்டெடுக்கப்பட்டன.",
+ "profHistDesc": "வேறு ஒரு செயலி உங்கள் சுயவிவரத்தை மாற்றியிருந்தால், வரலாற்றை வைத்திருக்கும் ரிலேயில் பழைய நகல் இன்னும் இருக்கலாம். அதைப் பயன்படுத்தினால் இங்கே காலியாக உள்ள புலங்கள் மட்டும் நிரப்பப்படும்.",
+ "profHistLook": "பழைய நகல்களைத் தேடு",
+ "profHistUse": "பயன்படுத்து",
+ "profHistNoName": "(பெயர் இல்லை)",
+ "profHistNone": "இங்கே இல்லாத எதுவும் எந்தப் பழைய நகலிலும் இல்லை.",
+ "profHistNothing": "அந்த நகலில் உள்ள அனைத்தும் ஏற்கனவே இங்கே உள்ளன.",
+ "profHistFilled": "பழைய நகலிலிருந்து {n} புலங்கள் நிரப்பப்பட்டன. வெளியிட சேமிக்கவும்.",
+ "profHist_name": "பெயர்",
+ "profHist_about": "அறிமுகம்",
+ "profHist_picture": "படம்",
+ "profHist_banner": "பேனர்",
"securityTitle": "பாதுகாப்பு",
"arkCoinsNone": "செலவுக் கணக்கில் இன்னும் நாணயங்கள் இல்லை.",
"profKeyLink": "பொது விசை",
@@ -247,6 +263,8 @@
"mediaNoImages": "இந்த ஊட்டத்தில் இன்னும் படங்கள் இல்லை.",
"mediaNoVideos": "இந்த ஊட்டத்தில் இன்னும் செங்குத்து வீடியோக்கள் இல்லை.",
"videoTapToPlay": "இயக்கத் தட்டவும்",
+ "videoPlay": "இயக்கு",
+ "videoPause": "இடைநிறுத்து",
"videoLargeTitle": "வீடியோக்கள்",
"videoLargeToggle": "பெரிய வீடியோக்களைத் தானாக இயக்கு",
"videoLargeHelp": "{mb} MB-க்கு மேல் அல்லது 1080p-க்கு மேலான வீடியோக்கள் உங்கள் டேட்டாவைச் சேமிக்க, தட்டும் வரை காத்திருக்கும். மெதுவான அல்லது டேட்டா சேமிப்பு இணைப்பில் வரம்பு குறைவு.",
@@ -261,5 +279,15 @@
"arkRenewViewTx": "சுற்றின் பரிவர்த்தனையைக் காண்க",
"pagerFetchingVideo": "அடுத்த வீடியோவைப் பெறுகிறது…",
"pagerFetchingPictures": "மேலும் படங்களைப் பெறுகிறது…",
- "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்"
+ "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்",
+ "onbGiftNameTitle": "இப்போது ஒரு பயனர்பெயரைத் தேர்ந்தெடுங்கள்",
+ "onbGiftNameBody": "ஒரு பெயரைப் பெற்று உங்கள் சொந்த பணம் பெறும் முகவரியைப் பெறுங்கள்; உங்கள் பெயரை மட்டும் வைத்தே மற்றவர்கள் பிட்காயின் அனுப்பலாம். பிறகு ஒரு படத்தையும் அட்டைப்படத்தையும் தேர்ந்தெடுங்கள்.",
+ "namesChangeTo": "{addr} ஆக மாற்று",
+ "schedToggle": "பின்னர் இடுகையிட திட்டமிடு",
+ "schedAt": "இடுகை நேரம்",
+ "schedBtn": "திட்டமிடு",
+ "schedDone": "{when} க்கு திட்டமிடப்பட்டது",
+ "schedTooSoon": "இப்போதிலிருந்து குறைந்தது இரண்டு நிமிடங்களுக்குப் பிந்தைய நேரத்தைத் தேர்ந்தெடுக்கவும்",
+ "schedListTitle": "திட்டமிடப்பட்டவை ({n})",
+ "schedCancelled": "திட்டமிட்ட இடுகை ரத்து செய்யப்பட்டது"
}
diff --git a/src/locales/te.json b/src/locales/te.json
index 7e60eaf..2f6c298 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -137,6 +137,10 @@
"giftSkip": "ప్రస్తుతానికి దాటవేయి",
"giftBackedUp": "నేను దాన్ని రాసుకున్నాను",
"giftLink": "కొంత Bitcoin బహుమతిగా ఇవ్వండి",
+ "giftNotePlaceholder": "ఒక సందేశం జోడించండి (ఐచ్ఛికం)",
+ "giftFromMe": "ఇది నా నుండి అని చెప్పు (క్లెయిమ్ చేసినవారు మిమ్మల్ని ఫాలో అవుతారు)",
+ "giftFrom": "పంపినవారు",
+ "giftClaimFollows": "క్లెయిమ్ చేస్తే వారిని ఫాలో అవుతారు",
"giftLinkDesc": "ఒక మొత్తాన్ని ముందుగా సంతకం చేసి లింక్‌గా ఇవ్వండి. దాన్ని తెరిచినవారు ఆ Bitcoinను తాము మాత్రమే నియంత్రించే సరికొత్త వాలెట్‌లోకి క్లెయిమ్ చేసుకుంటారు.",
"giftLinkWarn": "⚠ ఈ లింక్ తెరిచిన ఎవరైనా ఆ మొత్తాన్ని క్లెయిమ్ చేయగలరు, కాబట్టి మీరు బహుమతి ఇస్తున్న వ్యక్తికి మాత్రమే దీన్ని పంచుకోండి. మొత్తాలను తక్కువగా ఉంచండి.",
"giftAmountLabel": "బహుమతిగా ఇచ్చే మొత్తం",
@@ -145,6 +149,7 @@
"giftReserved": "దావా చేయని బహుమతి లింకులు ({n})",
"claimBody": "మీరు మాత్రమే నియంత్రించే సరికొత్త వాలెట్‌లోకి దాన్ని క్లెయిమ్ చేసుకోండి.",
"claimBtn": "క్లెయిమ్ చేసుకోండి",
+ "claimNowBtn": "ఇప్పుడే పొందండి",
"claimFailed": "ఈ బహుమతి ఇప్పటికే క్లెయిమ్ చేయబడింది లేదా ఇక అందుబాటులో లేదు.",
"claimedTitle": "క్లెయిమ్ అయింది!",
"claimedBody": "మీ Bitcoin మీ వద్దకు వస్తోంది. దాన్ని సురక్షితంగా ఉంచడానికి మీ రికవరీ ఫ్రేజ్‌ను బ్యాకప్ చేయండి.",
@@ -213,6 +218,17 @@
"followHistNone": "మీ జాబితాలో లేని వ్యక్తి ఉన్న పాత కాపీ ఏదీ లేదు.",
"followHistNothing": "ఆ కాపీలోని అందరూ ఇప్పటికే మీ జాబితాలో ఉన్నారు.",
"followHistRestored": "{n} ఫాలోలు పునరుద్ధరించబడ్డాయి.",
+ "profHistDesc": "మరో యాప్ మీ ప్రొఫైల్‌ను మార్చేస్తే, చరిత్రను ఉంచే రిలేలో పాత కాపీ ఇంకా ఉండవచ్చు. దాన్ని వాడితే ఇక్కడ ఖాళీగా ఉన్న ఫీల్డ్‌లు మాత్రమే నిండుతాయి.",
+ "profHistLook": "పాత కాపీలను వెతకండి",
+ "profHistUse": "వాడండి",
+ "profHistNoName": "(పేరు లేదు)",
+ "profHistNone": "ఇక్కడ లేనిది ఏ పాత కాపీలోనూ లేదు.",
+ "profHistNothing": "ఆ కాపీలో ఉన్నదంతా ఇప్పటికే ఇక్కడ ఉంది.",
+ "profHistFilled": "పాత కాపీ నుండి {n} ఫీల్డ్‌లు నింపబడ్డాయి. ప్రచురించడానికి సేవ్ చేయండి.",
+ "profHist_name": "పేరు",
+ "profHist_about": "పరిచయం",
+ "profHist_picture": "ఫోటో",
+ "profHist_banner": "బ్యానర్",
"securityTitle": "భద్రత",
"arkCoinsNone": "ఖర్చు ఖాతాలో ఇంకా నాణేలు లేవు.",
"profKeyLink": "పబ్లిక్ కీ",
@@ -245,6 +261,8 @@
"mediaNoImages": "ఈ ఫీడ్‌లో ఇంకా చిత్రాలు లేవు.",
"mediaNoVideos": "ఈ ఫీడ్‌లో ఇంకా నిలువు వీడియోలు లేవు.",
"videoTapToPlay": "ప్లే చేయడానికి నొక్కండి",
+ "videoPlay": "ప్లే",
+ "videoPause": "పాజ్",
"videoLargeTitle": "వీడియోలు",
"videoLargeToggle": "పెద్ద వీడియోలను ఆటో‌ప్లే చేయి",
"videoLargeHelp": "{mb} MB కంటే పెద్దవి లేదా 1080p పైబడిన వీడియోలు మీ డేటా ఆదా కోసం నొక్కే వరకు ఆగుతాయి. నెమ్మదైన లేదా డేటా సేవర్ కనెక్షన్‌లో పరిమితి తక్కువ.",
@@ -259,5 +277,15 @@
"arkRenewViewTx": "రౌండ్ లావాదేవీని చూడండి",
"pagerFetchingVideo": "తదుపరి వీడియోను తెస్తోంది…",
"pagerFetchingPictures": "మరిన్ని చిత్రాలను తెస్తోంది…",
- "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే"
+ "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే",
+ "onbGiftNameTitle": "ఇప్పుడు ఒక యూజర్‌నేమ్ ఎంచుకోండి",
+ "onbGiftNameBody": "ఒక పేరు తీసుకుని మీ సొంత చెల్లింపు చిరునామా పొందండి, అప్పుడు మీ పేరుతోనే ఎవరైనా మీకు బిట్‌కాయిన్ పంపవచ్చు. తర్వాత ఒక ఫోటో, ఒక కవర్ ఎంచుకోండి.",
+ "namesChangeTo": "{addr}కి మార్చు",
+ "schedToggle": "తర్వాత కోసం షెడ్యూల్ చేయండి",
+ "schedAt": "పోస్ట్ సమయం",
+ "schedBtn": "షెడ్యూల్ చేయండి",
+ "schedDone": "{when}కి షెడ్యూల్ చేయబడింది",
+ "schedTooSoon": "ఇప్పటి నుండి కనీసం రెండు నిమిషాల తర్వాతి సమయాన్ని ఎంచుకోండి",
+ "schedListTitle": "షెడ్యూల్ చేసినవి ({n})",
+ "schedCancelled": "షెడ్యూల్ చేసిన పోస్ట్ రద్దయింది"
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 1e60eeb..407bcc0 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -363,6 +363,10 @@
"giftWelcome": "Size Bitcoin gönderildi",
"giftBackedUp": "Yazdım",
"giftLink": "Bitcoin hediye et",
+ "giftNotePlaceholder": "Not ekle (isteğe bağlı)",
+ "giftFromMe": "Benden olduğunu söyle (alan seni takip eder)",
+ "giftFrom": "Gönderen",
+ "giftClaimFollows": "Alınca gönderen kişiyi takip edersin",
"giftLinkDesc": "Bir tutarı önceden imzalayıp bağlantı olarak verin. Bağlantıyı açan, Bitcoin’i yalnızca kendi kontrolündeki yepyeni bir cüzdana alır.",
"giftLinkWarn": "⚠ Bu bağlantıyı açan herkes tutarı talep edebilir, bu yüzden yalnızca hediye ettiğiniz kişiyle paylaşın. Tutarları küçük tutun.",
"giftLinkReveal": "Hediye bağlantısı oluştur",
@@ -424,6 +428,7 @@
"claimOr": "veya",
"goToWallet": "Cüzdana git",
"claimBtn": "Talep et",
+ "claimNowBtn": "Şimdi al",
"claimFeeNote": "~{n} ücret ücreti",
"claimFailed": "Bu hediye zaten talep edilmiş veya artık kullanılamıyor.",
"claimNotVisibleBody": "Paralar bu hediyeyi finanse ediyor bu cüzdanın ağ/data kaynağından görünür değil. Bu cüzdanın aynı ağ ve veri kaynağını gönderileyici olarak kullandığından emin olun, sonra tekrar deneyin.",
@@ -617,6 +622,8 @@
"onbSpendBody": "Bir kullanıcı adı talep edin ve kendi ödeme adresinizi alın - insanlar size anında Bitcoin gönderebilir, sadece adınızla.",
"onbNameWait": "İşler hazır olsun...",
"onbNotNow": "Şimdi Değil",
+ "onbGiftNameTitle": "Şimdi bir kullanıcı adı seç",
+ "onbGiftNameBody": "Bir isim al ve kendi ödeme adresine sahip ol; insanlar sana sadece adınla bitcoin gönderebilsin. Sonra bir fotoğraf ve kapak seç.",
"onbAvatarTitle": "görünümünü seçin",
"onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.",
"onbUpload": "Bir fotoğraf",
@@ -639,7 +646,7 @@
"nlPasskey": "Sign in with a passkey",
"nlKeyOrBunker": "nsec, private key, or bunker:",
"nlGo": "Giriş yapın",
- "nlHint": "Amber, Amethyst ve diğer uzaktan işaretçileri ile çalışır (bunker'ı http adrese atlar), veya bir tarayıcı uzatması. Bir geçmiş anahtar bir kez kullanılır ve asla depolanmaz.",
+ "nlHint": "Uzaktan imzalama uygulamalarıyla (bunker:// adreslerini yapıştırın) veya bir tarayıcı eklentisiyle çalışır. Yapıştırılan anahtar bir kez kullanılır ve asla saklanmaz.",
"nlApprove": "Approve the request in your signer",
"nlUnrecognized": "Bu özel bir anahtar ya da bir bunker gibi görünmüyor:",
"nlNewTitle": "Bu hesap için bir cüzdan oluşturun",
@@ -677,6 +684,7 @@
"namesClaim": "Talep Davası",
"namesClaimed": "You are now {name}",
"namesCopy": "Kopya adresi",
+ "namesChangeTo": "{addr} olarak değiştir",
"namesMoreOpen": "Daha fazla seçenek",
"namesMoreTitle": "Ödeme yapmak için daha fazla yol",
"namesMoreBolt11": "Yıldırım Fatura (BOLT 11)",
@@ -877,6 +885,17 @@
"followHistNone": "Hiçbir eski kopyada listende eksik olan biri yok.",
"followHistNothing": "O kopyadaki herkes zaten listende.",
"followHistRestored": "{n} takip geri yüklendi.",
+ "profHistDesc": "Başka bir uygulama profilinizi değiştirdiyse, geçmişi saklayan bir relayde daha eski bir kopya hâlâ olabilir. Birini kullanmak yalnızca burada boş olan alanları doldurur.",
+ "profHistLook": "Eski kopyaları ara",
+ "profHistUse": "Kullan",
+ "profHistNoName": "(adsız)",
+ "profHistNone": "Hiçbir eski kopyada burada eksik olan bir şey yok.",
+ "profHistNothing": "O kopyadaki her şey zaten burada.",
+ "profHistFilled": "Eski kopyadan {n} alan dolduruldu. Yayınlamak için kaydedin.",
+ "profHist_name": "ad",
+ "profHist_about": "hakkında",
+ "profHist_picture": "fotoğraf",
+ "profHist_banner": "kapak",
"securityTitle": "Güvenlik",
"arkCoinsNone": "Harcama’da henüz coin yok.",
"profKeyLink": "Açık anahtar",
@@ -909,6 +928,8 @@
"mediaNoImages": "Bu akışta henüz görsel yok.",
"mediaNoVideos": "Bu akışta henüz dikey video yok.",
"videoTapToPlay": "Oynatmak için dokun",
+ "videoPlay": "Oynat",
+ "videoPause": "Duraklat",
"videoLargeTitle": "Videolar",
"videoLargeToggle": "Büyük videoları otomatik oynat",
"videoLargeHelp": "{mb} MB’den büyük veya 1080p’nin üzerindeki videolar, akış verini tüketmesin diye dokunmanı bekler. Yavaş ya da veri tasarruflu bağlantıda sınır daha düşüktür.",
@@ -923,5 +944,12 @@
"arkRenewViewTx": "Turun işlemini görüntüle",
"pagerFetchingVideo": "Sonraki video getiriliyor…",
"pagerFetchingPictures": "Daha fazla resim getiriliyor…",
- "pagerCaughtUp": "Şimdilik bu kadar"
+ "pagerCaughtUp": "Şimdilik bu kadar",
+ "schedToggle": "Sonrası için zamanla",
+ "schedAt": "Paylaşım zamanı",
+ "schedBtn": "Zamanla",
+ "schedDone": "{when} için zamanlandı",
+ "schedTooSoon": "Şu andan en az iki dakika sonrasını seç",
+ "schedListTitle": "Zamanlanmış ({n})",
+ "schedCancelled": "Zamanlanmış gönderi iptal edildi"
}
diff --git a/src/locales/ur.json b/src/locales/ur.json
index 6efb6ec..bdcfa7a 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -137,6 +137,10 @@
"giftSkip": "ابھی چھوڑیں",
"giftBackedUp": "میں نے اسے لکھ لیا ہے",
"giftLink": "کچھ Bitcoin تحفے میں دیں",
+ "giftNotePlaceholder": "ایک نوٹ شامل کریں (اختیاری)",
+ "giftFromMe": "بتائیں کہ یہ میری طرف سے ہے (وصول کرنے والا آپ کو فالو کرے گا)",
+ "giftFrom": "بھیجنے والا",
+ "giftClaimFollows": "وصول کرنے پر آپ انہیں فالو کریں گے",
"giftLinkDesc": "ایک رقم کو بطور لنک پہلے سے دستخط کریں۔ جو بھی اسے کھولے وہ Bitcoin کو ایک بالکل نئے والیٹ میں وصول کر لیتا ہے جو صرف اسی کے کنٹرول میں ہوتا ہے۔",
"giftLinkWarn": "⚠ جو بھی یہ لنک کھولے وہ اس رقم کا دعویٰ کر سکتا ہے، اس لیے اسے صرف اسی شخص کے ساتھ شیئر کریں جسے آپ تحفہ دے رہے ہیں۔ رقم کم رکھیں۔",
"giftLinkReveal": "تحفہ لنک بنائیں",
@@ -147,6 +151,7 @@
"giftReserved": "غیر دعویٰ شدہ تحفہ لنکس ({n})",
"claimBody": "اسے ایک بالکل نئے والیٹ میں وصول کریں جو صرف آپ کے کنٹرول میں ہو۔",
"claimBtn": "وصول کریں",
+ "claimNowBtn": "ابھی حاصل کریں",
"claimFailed": "اس تحفے کا پہلے ہی دعویٰ کیا جا چکا ہے یا یہ اب دستیاب نہیں۔",
"claimedTitle": "وصول ہو گیا!",
"claimedBody": "آپ کا Bitcoin راستے میں ہے۔ اسے محفوظ رکھنے کے لیے اپنے بحالی فقرے کا بیک اپ بنا لیں۔",
@@ -215,6 +220,17 @@
"followHistNone": "کسی پرانی کاپی میں ایسا کوئی نہیں جو آپ کی فہرست میں نہ ہو۔",
"followHistNothing": "اس کاپی کے سب لوگ پہلے سے آپ کی فہرست میں ہیں۔",
"followHistRestored": "{n} فالو بحال ہو گئے۔",
+ "profHistDesc": "اگر کسی اور ایپ نے آپ کا پروفائل بدل دیا ہے تو تاریخ محفوظ رکھنے والے کسی ریلے پر پرانی نقل اب بھی ہو سکتی ہے۔ اسے استعمال کرنے سے یہاں صرف خالی خانے بھرتے ہیں۔",
+ "profHistLook": "پرانی نقلیں تلاش کریں",
+ "profHistUse": "استعمال کریں",
+ "profHistNoName": "(بغیر نام)",
+ "profHistNone": "کسی پرانی نقل میں ایسا کچھ نہیں جو یہاں نہ ہو۔",
+ "profHistNothing": "اس نقل کی ہر چیز پہلے سے یہاں موجود ہے۔",
+ "profHistFilled": "پرانی نقل سے {n} خانے بھرے گئے۔ شائع کرنے کے لیے محفوظ کریں۔",
+ "profHist_name": "نام",
+ "profHist_about": "تعارف",
+ "profHist_picture": "تصویر",
+ "profHist_banner": "بینر",
"securityTitle": "سیکیورٹی",
"arkCoinsNone": "خرچ میں ابھی کوئی کوائن نہیں۔",
"profKeyLink": "پبلک کی",
@@ -247,6 +263,8 @@
"mediaNoImages": "اس فیڈ میں ابھی کوئی تصویر نہیں۔",
"mediaNoVideos": "اس فیڈ میں ابھی کوئی عمودی ویڈیو نہیں۔",
"videoTapToPlay": "چلانے کے لیے ٹیپ کریں",
+ "videoPlay": "چلائیں",
+ "videoPause": "روکیں",
"videoLargeTitle": "ویڈیوز",
"videoLargeToggle": "بڑی ویڈیوز خود چلائیں",
"videoLargeHelp": "{mb} MB سے بڑی یا 1080p سے اوپر کی ویڈیوز ٹیپ کا انتظار کرتی ہیں، تاکہ فیڈ آپ کا ڈیٹا نہ کھائے۔ سست یا ڈیٹا بچت والے کنکشن پر حد کم ہوتی ہے۔",
@@ -261,5 +279,15 @@
"arkRenewViewTx": "راؤنڈ کی ٹرانزیکشن دیکھیں",
"pagerFetchingVideo": "اگلی ویڈیو لائی جا رہی ہے…",
"pagerFetchingPictures": "مزید تصاویر لائی جا رہی ہیں…",
- "pagerCaughtUp": "فی الحال بس اتنا ہی"
+ "pagerCaughtUp": "فی الحال بس اتنا ہی",
+ "onbGiftNameTitle": "اب ایک یوزرنیم چنیں",
+ "onbGiftNameBody": "ایک نام لیں اور اپنا ادائیگی کا پتہ پائیں، تاکہ لوگ صرف آپ کے نام سے آپ کو بٹ کوائن بھیج سکیں۔ پھر ایک تصویر اور ایک کور چنیں۔",
+ "namesChangeTo": "{addr} میں بدلیں",
+ "schedToggle": "بعد کے لیے شیڈول کریں",
+ "schedAt": "پوسٹ کا وقت",
+ "schedBtn": "شیڈول کریں",
+ "schedDone": "{when} کے لیے شیڈول",
+ "schedTooSoon": "ابھی سے کم از کم دو منٹ بعد کا وقت چنیں",
+ "schedListTitle": "شیڈول شدہ ({n})",
+ "schedCancelled": "شیڈول شدہ پوسٹ منسوخ ہو گئی"
}
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 415f4e7..5490c34 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -137,6 +137,10 @@
"giftSkip": "Bỏ qua lúc này",
"giftBackedUp": "Tôi đã ghi lại rồi",
"giftLink": "Tặng một ít Bitcoin",
+ "giftNotePlaceholder": "Thêm lời nhắn (không bắt buộc)",
+ "giftFromMe": "Cho biết là từ tôi (người nhận sẽ theo dõi bạn)",
+ "giftFrom": "Từ",
+ "giftClaimFollows": "Nhận quà sẽ theo dõi người gửi",
"giftLinkDesc": "Ký trước một số tiền thành một liên kết. Ai mở liên kết sẽ nhận số Bitcoin đó vào một ví hoàn toàn mới mà chỉ riêng họ kiểm soát.",
"giftLinkWarn": "⚠ Bất kỳ ai mở liên kết này đều có thể nhận số tiền, vì vậy chỉ chia sẻ với người bạn muốn tặng. Hãy giữ số tiền nhỏ.",
"giftAmountLabel": "Số tiền muốn tặng",
@@ -145,6 +149,7 @@
"giftReserved": "Liên kết quà chưa nhận ({n})",
"claimBody": "Nhận nó vào một ví hoàn toàn mới mà chỉ riêng bạn kiểm soát.",
"claimBtn": "Nhận ngay",
+ "claimNowBtn": "Nhận ngay",
"claimFailed": "Món quà này đã được nhận hoặc không còn khả dụng.",
"claimedTitle": "Đã nhận!",
"claimedBody": "Bitcoin của bạn đang trên đường tới. Hãy sao lưu cụm từ khôi phục để giữ an toàn.",
@@ -215,6 +220,17 @@
"followHistNone": "Không bản cũ nào có người mà danh sách của bạn đang thiếu.",
"followHistNothing": "Mọi người trong bản đó đã có trong danh sách của bạn.",
"followHistRestored": "Đã khôi phục {n} người theo dõi.",
+ "profHistDesc": "Nếu một ứng dụng khác đã thay thế hồ sơ của bạn, bản cũ hơn có thể vẫn còn trên relay lưu lịch sử. Dùng một bản chỉ điền vào các ô đang trống ở đây.",
+ "profHistLook": "Tìm bản cũ hơn",
+ "profHistUse": "Dùng",
+ "profHistNoName": "(không tên)",
+ "profHistNone": "Không bản cũ nào có thứ còn thiếu ở đây.",
+ "profHistNothing": "Mọi thứ trong bản đó đã có ở đây.",
+ "profHistFilled": "Đã điền {n} ô từ bản cũ hơn. Lưu để đăng.",
+ "profHist_name": "tên",
+ "profHist_about": "tiểu sử",
+ "profHist_picture": "ảnh",
+ "profHist_banner": "ảnh bìa",
"securityTitle": "Bảo mật",
"arkCoinsNone": "Chưa có coin nào trong Chi tiêu.",
"profKeyLink": "Khóa công khai",
@@ -247,6 +263,8 @@
"mediaNoImages": "Bảng tin này chưa có ảnh.",
"mediaNoVideos": "Bảng tin này chưa có video dọc.",
"videoTapToPlay": "Chạm để phát",
+ "videoPlay": "Phát",
+ "videoPause": "Tạm dừng",
"videoLargeTitle": "Video",
"videoLargeToggle": "Tự động phát video lớn",
"videoLargeHelp": "Video trên {mb} MB hoặc cao hơn 1080p sẽ chờ bạn chạm, để bảng tin không ngốn dữ liệu. Giới hạn thấp hơn khi kết nối chậm hoặc tiết kiệm dữ liệu.",
@@ -261,5 +279,15 @@
"arkRenewViewTx": "Xem giao dịch của vòng",
"pagerFetchingVideo": "Đang tải video tiếp theo…",
"pagerFetchingPictures": "Đang tải thêm ảnh…",
- "pagerCaughtUp": "Tạm thời chỉ có vậy"
+ "pagerCaughtUp": "Tạm thời chỉ có vậy",
+ "onbGiftNameTitle": "Giờ hãy chọn tên người dùng",
+ "onbGiftNameBody": "Nhận một cái tên và địa chỉ thanh toán của riêng bạn, để mọi người gửi bitcoin cho bạn chỉ bằng tên. Sau đó chọn ảnh đại diện và ảnh bìa.",
+ "namesChangeTo": "Đổi thành {addr}",
+ "schedToggle": "Hẹn giờ đăng",
+ "schedAt": "Đăng lúc",
+ "schedBtn": "Hẹn giờ",
+ "schedDone": "Đã hẹn lúc {when}",
+ "schedTooSoon": "Chọn thời điểm ít nhất hai phút kể từ bây giờ",
+ "schedListTitle": "Đã hẹn ({n})",
+ "schedCancelled": "Đã huỷ bài hẹn giờ"
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 6b3cc49..4fc2f4b 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -364,6 +364,10 @@
"giftWelcome": "有人给你转了 Bitcoin",
"giftBackedUp": "我已经抄写下来了",
"giftLink": "赠送一些 Bitcoin",
+ "giftNotePlaceholder": "添加留言(可选)",
+ "giftFromMe": "显示是我送的(领取者会关注你)",
+ "giftFrom": "来自",
+ "giftClaimFollows": "领取后会关注对方",
"giftLinkDesc": "将一笔金额预签为一个链接。打开链接的人可将这笔 Bitcoin 领取到一个仅由他们自己掌控的全新钱包中。",
"giftLinkWarn": "⚠ 任何打开此链接的人都能领取这笔金额,所以请仅分享给你要赠送的对象。请保持金额较小。",
"giftLinkReveal": "创建赠送链接",
@@ -425,6 +429,7 @@
"claimOr": "或者说",
"goToWallet": "转到钱包",
"claimBtn": "领取",
+ "claimNowBtn": "立即领取",
"claimFeeNote": "~ (法语){n} 收费",
"claimFailed": "此礼物已被领取或不再可用。",
"claimNotVisibleBody": "从这个钱包的网络/数据来源看, 确保这个钱包使用与发送者相同的网络和数据源,然后再次尝试.",
@@ -618,6 +623,8 @@
"onbSpendBody": "要求一个用户名并获得你自己的付款地址,人们可以立刻发送你比特币,只要你的名字.",
"onbNameWait": "准备...",
"onbNotNow": "现在不行",
+ "onbGiftNameTitle": "现在选一个用户名",
+ "onbGiftNameBody": "领取一个名字,获得你自己的收款地址,别人只用你的名字就能给你发比特币。然后选一张头像和封面。",
"onbAvatarTitle": "选择你的外观",
"onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改",
"onbUpload": "上传相片",
@@ -640,7 +647,7 @@
"nlPasskey": "用密码签名",
"nlKeyOrBunker": "nsec,私人钥匙,或地堡://.",
"nlGo": "登录",
- "nlHint": "与Amber,Amethyst等远程签名者(打上他们的地堡/地址)合作,或是一个浏览器扩展. 粘贴的密钥使用一次, 并且从未被存储 。",
+ "nlHint": "支持远程签名应用(粘贴其 bunker:// 地址)或浏览器扩展。粘贴的密钥只使用一次,从不保存。",
"nlApprove": "批准您签名人的请求 。",
"nlUnrecognized": "这不是私人钥匙或地堡地址。",
"nlNewTitle": "为此账户创建钱包",
@@ -678,6 +685,7 @@
"namesClaim": "索赔",
"namesClaimed": "你现在是 {name}",
"namesCopy": "复制地址",
+ "namesChangeTo": "改为 {addr}",
"namesMoreOpen": "更多选项",
"namesMoreTitle": "更多赚钱的方法",
"namesMoreBolt11": "闪电发票(BOLT 11)",
@@ -878,6 +886,17 @@
"followHistNone": "没有旧副本包含你列表中缺少的人。",
"followHistNothing": "该副本中的所有人都已在你的列表中。",
"followHistRestored": "已恢复 {n} 个关注。",
+ "profHistDesc": "如果其他应用替换了你的资料,保留历史的中继上可能还有旧副本。使用它只会填入这里为空的字段。",
+ "profHistLook": "查找旧副本",
+ "profHistUse": "使用",
+ "profHistNoName": "(无名称)",
+ "profHistNone": "没有旧副本包含这里缺少的内容。",
+ "profHistNothing": "那份副本里的内容这里都已有了。",
+ "profHistFilled": "已从旧副本填入 {n} 个字段。保存即可发布。",
+ "profHist_name": "名称",
+ "profHist_about": "简介",
+ "profHist_picture": "头像",
+ "profHist_banner": "横幅",
"securityTitle": "安全",
"arkCoinsNone": "支出账户中还没有币。",
"profKeyLink": "公钥",
@@ -910,6 +929,8 @@
"mediaNoImages": "此动态中还没有图片。",
"mediaNoVideos": "此动态中还没有竖屏视频。",
"videoTapToPlay": "点按播放",
+ "videoPlay": "播放",
+ "videoPause": "暂停",
"videoLargeTitle": "视频",
"videoLargeToggle": "自动播放大视频",
"videoLargeHelp": "超过 {mb} MB 或高于 1080p 的视频会等你点按再播放,以免动态耗尽流量。在慢速或省流量连接下,上限更低。",
@@ -924,5 +945,12 @@
"arkRenewViewTx": "查看该轮交易",
"pagerFetchingVideo": "正在获取下一个视频…",
"pagerFetchingPictures": "正在获取更多图片…",
- "pagerCaughtUp": "暂时就这些了"
+ "pagerCaughtUp": "暂时就这些了",
+ "schedToggle": "定时发布",
+ "schedAt": "发布时间",
+ "schedBtn": "定时",
+ "schedDone": "已定时于 {when}",
+ "schedTooSoon": "请选择至少两分钟之后的时间",
+ "schedListTitle": "已定时({n})",
+ "schedCancelled": "已取消定时帖子"
}
diff --git a/src/marmot-client.js b/src/marmot-client.js
index b514216..707003f 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -16,7 +16,7 @@ import { dlog } from './debug.js';
// Where the groups we found live. Signed into group state, so every member
// reads and writes the same list. Not relay.damus.io or relay.nostr.band:
// White Noise (MDK's RETIRED_RELAY_HOSTS) never dials those.
-export const GROUP_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+export const GROUP_RELAYS = ['wss://messages.coinos.io', 'wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// Where White Noise looks people up. It will not invite an account whose
// relay lists (kinds 10002 and 10050) it cannot find, and one whose profile
diff --git a/src/media-servers.js b/src/media-servers.js
new file mode 100644
index 0000000..e2220d7
--- /dev/null
+++ b/src/media-servers.js
@@ -0,0 +1,27 @@
+// Upload destinations are a device preference, separate from wallet sync storage.
+const KEY = 'btc-wallet-media-servers';
+export function parseMediaServers(text) {
+ const entries = String(text).trim().split(/\s+/).filter(Boolean);
+ if (!entries.length) throw new Error('invalid');
+ const servers = [...new Set(entries.map((entry) => {
+ let url;
+ try { url = new URL(entry); } catch { throw new Error('invalid'); }
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/*$/.test(url.pathname)) throw new Error('invalid');
+ return url.origin;
+ }))];
+ if (servers.length > 6) throw new Error('invalid');
+ return servers;
+}
+export function customMediaServers() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(KEY));
+ return Array.isArray(saved) ? parseMediaServers(saved.join('\n')) : null;
+ } catch { return null; }
+}
+export const mediaServers = (defaults) => customMediaServers() || defaults;
+export function saveMediaServers(text) {
+ const servers = parseMediaServers(text);
+ localStorage.setItem(KEY, JSON.stringify(servers));
+ return servers;
+}
+export function resetMediaServers() { localStorage.removeItem(KEY); }
diff --git a/src/media-timeline.js b/src/media-timeline.js
new file mode 100644
index 0000000..d8ae008
--- /dev/null
+++ b/src/media-timeline.js
@@ -0,0 +1,140 @@
+// Timestamp annotations retain the usual Nostr event target. `media` is a
+// client extension; the r tag is a portable media-fragment link.
+export function mediaMoment(url, seconds) {
+ try {
+ const u = new URL(url);
+ if (!/^https?:$/.test(u.protocol) || !Number.isFinite(seconds) || seconds < 0 || seconds > Number.MAX_SAFE_INTEGER) return null;
+ u.hash = '';
+ return { url: u.href, seconds: Math.floor(seconds) };
+ } catch { return null; }
+}
+export function momentTags(moment) {
+ const m = moment && mediaMoment(moment.url, moment.seconds);
+ return m ? [['media', m.url, String(m.seconds)], ['r', m.url + '#t=' + m.seconds]] : [];
+}
+export function readMoment(event, depth = 0) {
+ if (depth > 1) return null;
+ if (event?.kind === 9735) {
+ try { return readMoment(JSON.parse(event.tags.find(t => t[0] === 'description')?.[1]), depth + 1); } catch { return null; }
+ }
+ const tag = event?.tags?.find(t => t[0] === 'media');
+ return tag && /^\d+$/.test(tag[2] || '') ? mediaMoment(tag[1], Number(tag[2])) : null;
+}
+export function mediaClock(seconds) {
+ const s = Math.max(0, Math.floor(seconds || 0));
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
+}
+export function waveformFromTags(event, url) {
+ const tag = event?.tags?.find(t => t[0] === 'imeta' && t.includes('url ' + url));
+ const raw = tag?.find(t => t.startsWith('waveform '))?.slice(9);
+ if (!raw) return null;
+ const values = raw.trim().split(/\s+/).map(Number);
+ return values.length > 1 && values.length <= 4096 && values.every(n => Number.isFinite(n) && n >= 0) ? values : null;
+}
+export function waveformPeaks(buffer, count = 96) {
+ const peaks = new Array(count).fill(0);
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
+ const data = buffer.getChannelData(ch);
+ for (let i = 0; i < count; i++) {
+ const end = Math.floor((i + 1) * data.length / count);
+ for (let j = Math.floor(i * data.length / count); j < end; j++) peaks[i] = Math.max(peaks[i], Math.abs(data[j]));
+ }
+ }
+ return peaks;
+}
+const waves = new Map();
+let decoding = Promise.resolve();
+export function loadWaveform(url) {
+ if (waves.has(url)) return waves.get(url);
+ const job = decoding.then(async () => {
+ const limit = 20 * 1024 * 1024;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30000);
+ let ctx;
+ try {
+ const response = await fetch(url, { signal: controller.signal, credentials: 'omit' });
+ if (!response.ok || Number(response.headers.get('content-length')) > limit) throw new Error('size');
+ const reader = response.body.getReader(), parts = [];
+ let size = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > limit) { await reader.cancel(); throw new Error('size'); }
+ parts.push(value);
+ }
+ const Audio = globalThis.AudioContext || globalThis.webkitAudioContext;
+ ctx = new Audio({ sampleRate: 8000 });
+ const buffer = await ctx.decodeAudioData(await new Blob(parts).arrayBuffer());
+ return waveformPeaks(buffer);
+ } catch { return null; }
+ finally { clearTimeout(timer); controller.abort(); if (ctx) await ctx.close().catch(() => {}); }
+ });
+ decoding = job.then(() => {});
+ waves.set(url, job);
+ if (waves.size > 24) waves.delete(waves.keys().next().value);
+ return job;
+}
+
+// Where a moment sits along a bar: a percentage of the clip, or null.
+export function markAt(seconds, duration) {
+ return Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, seconds / duration * 100)) : null;
+}
+
+// The live player owns this subtree, so morphing cannot replace its handlers
+// with closures pointing at a detached player. What was said, reacted or
+// zapped at a moment sits on the bar at that moment; replies, reactions and
+// zaps made while a clip plays carry its time on their own (see momentOf).
+export function mediaTimeline(player, url, { h, event, moments, glyph, label, onMarks = null }) {
+ const audio = player.tagName === 'AUDIO';
+ const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() });
+ box._skipMorph = true;
+ const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' });
+ const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true });
+ const marks = h('div', { class: 'media-marks' });
+ const seek = h('div', { class: 'media-seek' }, bars, range, marks);
+ const time = h('span', { class: 'small muted media-time' }, '0:00');
+ const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' });
+ box.append(seek, time, detail);
+ let waveStarted = false, signature = '';
+ const draw = values => {
+ if (!values) return;
+ const max = Math.max(...values) || 1;
+ const stride = Math.max(1, Math.ceil(values.length / 96));
+ bars.replaceChildren();
+ for (let i = 0; i < values.length; i += stride) bars.append(h('i', { style: 'height:' + Math.max(3, 100 * Math.max(...values.slice(i, i + stride)) / max) + '%' }));
+ seek.classList.add('has-wave');
+ };
+ const jump = seconds => { if (Number.isFinite(player.duration)) player.currentTime = Math.min(seconds, player.duration); };
+ range.addEventListener('input', () => jump(Number(range.value) * player.duration / 1000));
+ const update = () => {
+ const ev = event();
+ const finite = Number.isFinite(player.duration) && player.duration > 0;
+ range.disabled = !finite;
+ range.value = finite ? player.currentTime / player.duration * 1000 : 0;
+ range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0));
+ seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%');
+ time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0);
+ if (audio && !waveStarted && finite) {
+ waveStarted = true;
+ const published = waveformFromTags(ev, url);
+ if (published) draw(published);
+ else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw);
+ }
+ const items = ev && finite ? moments(ev.id, url) : [];
+ const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0);
+ // a video has its own seek bar: this one shows under it only to carry
+ // what was said at a moment (two bars read as a glitch on a phone)
+ box.classList.toggle('has-marks', items.length > 0);
+ if (onMarks) onMarks(items.length > 0);
+ if (signature !== next) {
+ signature = next;
+ marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, player.duration) + '%',
+ onClick: e => { e.stopPropagation(); jump(x.moment.seconds); detail.textContent = mediaClock(x.moment.seconds) + ' ' + x.text; } }, glyph(x))));
+ }
+ };
+ // Fetching/decoding starts only once the user plays (or explicitly seeks).
+ for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update);
+ return box;
+}
diff --git a/src/media-upload.js b/src/media-upload.js
index c06771b..7f020cf 100644
--- a/src/media-upload.js
+++ b/src/media-upload.js
@@ -1,3 +1,4 @@
+import { mediaServers } from './media-servers.js';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
@@ -19,6 +20,16 @@ export class MediaUploadError extends Error {
}
}
+// Private Blossom hosts authorize the user's Nostr identity, which can differ
+// from the wallet's sync key. Resolve once for the whole upload (including HLS).
+export async function mediaUploadSigner(wallet, hook) {
+ const id = hook('nostrLoginIdentity');
+ if (!id) return (event) => wallet.nostrSign(event);
+ const signer = id.signer || await hook('nostrLoginResume');
+ if (!signer) throw new MediaUploadError('no-identity');
+ return (event) => signer.signEvent(event);
+}
+
async function fileHash(file) {
const hash = sha256.create();
const chunk = 4 * 1024 * 1024;
@@ -62,10 +73,12 @@ function putWithProgress(url, { body, headers, signal }, onProgress) {
});
}
+const hostOf = (server) => { try { return new URL(server).hostname.replace(/^blossom\./, ''); } catch { return server; } };
+
// BUD-02 upload. `signEvent` is deliberately injected: the app can use its
-// always-available wallet identity, while tests need no secret key.
+// signed-in Nostr identity, while tests need no secret key.
export async function uploadPublicMedia(file, signEvent, {
- servers = PUBLIC_MEDIA_SERVERS,
+ servers = mediaServers(PUBLIC_MEDIA_SERVERS),
fetcher = globalThis.fetch,
timeoutMs = 5 * 60_000,
onProgress = null, // (fraction 0..1) as the bytes go out
@@ -86,7 +99,7 @@ export async function uploadPublicMedia(file, signEvent, {
// upload timeout. Any HTTP answer, even an error, means it's there.
if (i < servers.length - 1) {
try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); }
- catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; }
+ catch (error) { failures.push({ host: hostOf(server), status: 0, reason: 'unreachable' }); continue; }
}
try {
const init = {
@@ -105,13 +118,60 @@ export async function uploadPublicMedia(file, signEvent, {
try { body = await response.json(); } catch {}
const url = response.ok && responseUrl(body);
if (url) return mediaUrl(url, server, file);
- failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' });
+ failures.push({ host: hostOf(server), status: response.status, reason: response.headers.get('x-reason') || body?.message || '' });
} catch (error) {
- failures.push({ status: 0, reason: error?.message || '' });
+ failures.push({ host: hostOf(server), status: 0, reason: error?.message || '' });
}
}
- const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; ');
+ const details = failures.map((x) => x.host + ': ' + ([x.status, x.reason].filter(Boolean).join(' ') || '?')).join('; ');
+ if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details);
if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details);
if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details);
throw new MediaUploadError('failed', details);
}
+
+// An HLS stream (streamVideo) onto one host: the media files first, then
+// each playlist with its files' addresses written in, then the master
+// playlist naming those — its address is the post's. One host for the lot,
+// and one that serves a playlist as one (ditto and nostr.download do;
+// primal's says text/plain, nostr.build won't take it); a host that refuses
+// any piece is passed over for the next. `files` maps each name the
+// playlists use to its File.
+export const STREAM_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.primal.net'];
+export function rewritePlaylist(text, urls) {
+ const to = (p) => urls.get(p) || p;
+ return text.split('\n').map((line) => {
+ const l = line.trim();
+ if (l && !l.startsWith('#')) return to(l);
+ return line.replace(/URI="([^"]+)"/g, (m, p) => `URI="${to(p)}"`);
+ }).join('\n');
+}
+export async function uploadStream({ files, master }, signEvent, { servers = mediaServers(STREAM_SERVERS), fetcher, onProgress = null } = {}) {
+ const media = [...files.keys()].filter((p) => !/\.m3u8$/.test(p));
+ const lists = [...files.keys()].filter((p) => /\.m3u8$/.test(p) && p !== master);
+ const total = [...files.values()].reduce((n, f) => n + f.size, 0) || 1;
+ let lastError = null;
+ for (const server of servers) {
+ let done = 0;
+ const put = async (file) => {
+ const url = await uploadPublicMedia(file, signEvent, {
+ servers: [server], ...(fetcher ? { fetcher } : {}),
+ onProgress: onProgress && ((p) => onProgress(Math.min(1, (done + p * file.size) / total))),
+ });
+ done += file.size;
+ if (onProgress) onProgress(Math.min(1, done / total)); // (the playlists grow a little with the addresses in)
+ return url;
+ };
+ try {
+ const urls = new Map();
+ for (const p of media) urls.set(p, await put(files.get(p)));
+ for (const p of lists) {
+ const text = rewritePlaylist(await files.get(p).text(), urls);
+ urls.set(p, await put(new File([text], p, { type: 'application/vnd.apple.mpegurl' })));
+ }
+ const text = rewritePlaylist(await files.get(master).text(), urls);
+ return await put(new File([text], master, { type: 'application/vnd.apple.mpegurl' }));
+ } catch (error) { lastError = error; }
+ }
+ throw lastError || new MediaUploadError('failed');
+}
diff --git a/src/negentropy.js b/src/negentropy.js
new file mode 100644
index 0000000..83f65ae
--- /dev/null
+++ b/src/negentropy.js
@@ -0,0 +1,197 @@
+// Negentropy (NIP-77): set reconciliation with a relay. The client holds a
+// set of (created_at, id) pairs — the events it already has for a filter —
+// and the relay holds its own. Instead of the relay re-sending every event
+// the filter matches (a DM catch-up re-served 200 gift wraps, half a
+// megabyte, on every subscribe; a notification refresh a hundred kilobytes),
+// the two sides exchange fingerprints of ranges of their sets, split the
+// ranges that differ, and end with the ids each side lacks — a few hundred
+// bytes, usually in one round. The events themselves are then fetched by id.
+//
+// Protocol version 1 (0x61), as strfry speaks it. A port of Doug Hoyte's
+// reference client (github.com/hoytech/negentropy, MIT, © 2023), trimmed to
+// the initiator's side plus what a test needs to play the relay.
+// Hashing is WebCrypto SHA-256, so everything here is async.
+
+const PROTOCOL_VERSION = 0x61;
+const ID_SIZE = 32;
+const FINGERPRINT_SIZE = 16;
+const MODE = { skip: 0, fingerprint: 1, idList: 2 };
+const MAX_TS = Number.MAX_SAFE_INTEGER; // "infinity": the bound past every item
+
+// ---- bytes ----------------------------------------------------------------
+const hexOf = (a) => { let s = ''; for (let i = 0; i < a.length; i++) s += (a[i] < 16 ? '0' : '') + a[i].toString(16); return s; };
+const bytesOf = (h) => { if (h instanceof Uint8Array) return h; if (h.length % 2) throw new Error('odd hex'); const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a; };
+const cmpBytes = (a, b) => { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return a.length === b.length ? 0 : a.length < b.length ? -1 : 1; };
+const cmpItem = (a, b) => (a.ts === b.ts ? cmpBytes(a.id, b.id) : a.ts < b.ts ? -1 : 1);
+
+class Out {
+ constructor() { this.parts = []; this.length = 0; }
+ push(a) { if (Array.isArray(a)) a = Uint8Array.from(a); this.parts.push(a); this.length += a.length; }
+ bytes() { const o = new Uint8Array(this.length); let p = 0; for (const a of this.parts) { o.set(a, p); p += a.length; } return o; }
+}
+class In {
+ constructor(a) { this.a = a; this.p = 0; }
+ get left() { return this.a.length - this.p; }
+ byte() { if (this.p >= this.a.length) throw new Error('negentropy: message ends early'); return this.a[this.p++]; }
+ take(n) { if (this.p + n > this.a.length) throw new Error('negentropy: message ends early'); const s = this.a.subarray(this.p, this.p + n); this.p += n; return s; }
+}
+// varints, big-endian 7-bit groups with a continuation bit (the protocol's)
+const putVar = (o, n) => { if (n === 0) return o.push([0]); const g = []; while (n > 0) { g.push(n % 128); n = Math.floor(n / 128); } g.reverse(); for (let i = 0; i < g.length - 1; i++) g[i] |= 128; o.push(g); };
+const getVar = (i) => { let n = 0; for (;;) { const b = i.byte(); n = n * 128 + (b & 127); if (!(b & 128)) return n; } };
+
+// ---- the fingerprint of a range: the ids summed mod 2^256, hashed with the count
+const sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
+function addInto(acc, id) {
+ let carry = 0;
+ for (let i = 0; i < ID_SIZE; i++) { const s = acc[i] + id[i] + carry; acc[i] = s & 255; carry = s >> 8; }
+}
+async function fingerprint(items, begin, end) {
+ const acc = new Uint8Array(ID_SIZE);
+ for (let i = begin; i < end; i++) addInto(acc, items[i].id);
+ const o = new Out(); o.push(acc); putVar(o, end - begin);
+ return (await sha256(o.bytes())).subarray(0, FINGERPRINT_SIZE);
+}
+
+// ---- the set ----------------------------------------------------------------
+// `items`: [[created_at, id hex], ...] in any order; duplicates are dropped.
+export class NegSet {
+ constructor(items = []) {
+ const seen = new Set();
+ this.items = [];
+ for (const [ts, id] of items) {
+ if (typeof id !== 'string' || id.length !== 64 || seen.has(id)) continue;
+ seen.add(id);
+ this.items.push({ ts: Math.max(0, Math.floor(ts || 0)), id: bytesOf(id) });
+ }
+ this.items.sort(cmpItem);
+ }
+ get size() { return this.items.length; }
+ // the first index in [begin, end) at or past the bound
+ lowerBound(begin, end, bound) {
+ let lo = begin, hi = end;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (cmpItem(this.items[m], bound) < 0) lo = m + 1; else hi = m; }
+ return lo;
+ }
+}
+
+// ---- one side of a reconciliation ------------------------------------------
+export class Negentropy {
+ constructor(set, { frameSizeLimit = 0 } = {}) {
+ if (frameSizeLimit && frameSizeLimit < 4096) throw new Error('negentropy: frame size limit too small');
+ this.set = set;
+ this.frameSizeLimit = frameSizeLimit;
+ this.initiator = false;
+ this.lastTsIn = 0; this.lastTsOut = 0;
+ }
+ // the client's opening message (hex)
+ async initiate() {
+ this.initiator = true;
+ const o = new Out(); o.push([PROTOCOL_VERSION]);
+ await this.splitRange(0, this.set.size, { ts: MAX_TS, id: new Uint8Array(0) }, o);
+ return hexOf(o.bytes());
+ }
+ // One message in, one out. For the initiator the answer is null once the
+ // sets are reconciled; `have` are ids we hold that the other side lacks,
+ // `need` the ones it holds that we lack. The non-initiator (a relay, or
+ // a test playing one) always answers and collects nothing.
+ async reconcile(hex) {
+ const q = new In(bytesOf(hex));
+ const have = [], need = [];
+ this.lastTsIn = this.lastTsOut = 0;
+ const full = new Out(); full.push([PROTOCOL_VERSION]);
+ const version = q.byte();
+ if (version < 0x60 || version > 0x6f) throw new Error('negentropy: not a negentropy message');
+ if (version !== PROTOCOL_VERSION) {
+ if (this.initiator) throw new Error('negentropy: unsupported protocol version ' + (version - 0x60));
+ return { next: hexOf(full.bytes()), have, need };
+ }
+ const items = this.set.items, size = this.set.size;
+ let prevBound = { ts: 0, id: new Uint8Array(0) }, prevIndex = 0, skip = false;
+ while (q.left) {
+ const o = new Out();
+ const doSkip = () => { if (skip) { skip = false; this.putBound(o, prevBound); putVar(o, MODE.skip); } };
+ const bound = this.getBound(q);
+ const mode = getVar(q);
+ const lower = prevIndex;
+ let upper = this.set.lowerBound(prevIndex, size, bound);
+ if (mode === MODE.skip) skip = true;
+ else if (mode === MODE.fingerprint) {
+ const theirs = q.take(FINGERPRINT_SIZE);
+ const ours = await fingerprint(items, lower, upper);
+ if (cmpBytes(theirs, ours) !== 0) { doSkip(); await this.splitRange(lower, upper, bound, o); }
+ else skip = true;
+ } else if (mode === MODE.idList) {
+ const n = getVar(q);
+ const theirs = new Map();
+ for (let i = 0; i < n; i++) { const id = q.take(ID_SIZE); if (this.initiator) theirs.set(hexOf(id), id); }
+ if (this.initiator) {
+ skip = true;
+ for (let i = lower; i < upper; i++) {
+ const k = hexOf(items[i].id);
+ if (theirs.has(k)) theirs.delete(k); else have.push(k);
+ }
+ for (const k of theirs.keys()) need.push(k);
+ } else {
+ doSkip();
+ const ids = new Out(); let count = 0, endBound = bound;
+ for (let i = lower; i < upper; i++) {
+ if (this.overFrame(full.length + ids.length)) { endBound = items[i]; upper = i; break; }
+ ids.push(items[i].id); count++;
+ }
+ this.putBound(o, endBound); putVar(o, MODE.idList); putVar(o, count); o.push(ids.bytes());
+ full.push(o.bytes()); o.parts = []; o.length = 0;
+ }
+ } else throw new Error('negentropy: unexpected mode ' + mode);
+ if (this.overFrame(full.length + o.length)) {
+ // the frame is full: one fingerprint for everything left
+ const rest = await fingerprint(items, upper, size);
+ this.putBound(full, { ts: MAX_TS, id: new Uint8Array(0) }); putVar(full, MODE.fingerprint); full.push(rest);
+ break;
+ }
+ full.push(o.bytes());
+ prevIndex = upper; prevBound = bound;
+ }
+ return { next: full.length === 1 && this.initiator ? null : hexOf(full.bytes()), have, need };
+ }
+ // a range that differs: sixteen fingerprinted buckets, or the ids outright when small
+ async splitRange(lower, upper, upperBound, o) {
+ const items = this.set.items, n = upper - lower, buckets = 16;
+ if (n < buckets * 2) {
+ this.putBound(o, upperBound); putVar(o, MODE.idList); putVar(o, n);
+ for (let i = lower; i < upper; i++) o.push(items[i].id);
+ return;
+ }
+ const per = Math.floor(n / buckets), extra = n % buckets;
+ let cur = lower;
+ for (let b = 0; b < buckets; b++) {
+ const size = per + (b < extra ? 1 : 0);
+ const fp = await fingerprint(items, cur, cur + size);
+ cur += size;
+ const next = cur === upper ? upperBound : minimalBound(items[cur - 1], items[cur]);
+ this.putBound(o, next); putVar(o, MODE.fingerprint); o.push(fp);
+ }
+ }
+ overFrame(n) { return !!this.frameSizeLimit && n > this.frameSizeLimit - 200; }
+ // bounds: a timestamp as a delta from the last one (+1; 0 = infinity), then a prefix of an id
+ getBound(q) {
+ let ts = getVar(q);
+ ts = ts === 0 ? MAX_TS : ts - 1;
+ if (this.lastTsIn === MAX_TS || ts === MAX_TS) { this.lastTsIn = MAX_TS; ts = MAX_TS; }
+ else { ts += this.lastTsIn; this.lastTsIn = ts; }
+ const len = getVar(q);
+ if (len > ID_SIZE) throw new Error('negentropy: bound key too long');
+ return { ts, id: q.take(len) };
+ }
+ putBound(o, b) {
+ if (b.ts === MAX_TS) { this.lastTsOut = MAX_TS; putVar(o, 0); }
+ else { putVar(o, b.ts - this.lastTsOut + 1); this.lastTsOut = b.ts; }
+ putVar(o, b.id.length); o.push(b.id);
+ }
+}
+// the shortest bound that separates two neighbouring items
+function minimalBound(prev, cur) {
+ if (cur.ts !== prev.ts) return { ts: cur.ts, id: new Uint8Array(0) };
+ let shared = 0;
+ while (shared < ID_SIZE && cur.id[shared] === prev.id[shared]) shared++;
+ return { ts: cur.ts, id: cur.id.subarray(0, shared + 1) };
+}
diff --git a/src/nostr-login.js b/src/nostr-login.js
index fe60261..3357f61 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -324,7 +324,7 @@ let amberWatch = null;
const amberListeners = new Set();
export const onAmberChange = (fn) => { amberListeners.add(fn); return () => amberListeners.delete(fn); };
const amberChanged = () => { for (const fn of amberListeners) { try { fn(); } catch {} } };
-export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts } : null);
+export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts, needsTap: !!amberReq.needsTap } : null);
const HEX64 = /^[0-9a-f]{64}$/i;
// What an answer of this type looks like — anything else on the clipboard
// is somebody's grocery list, not a signature.
@@ -384,7 +384,10 @@ function amberWatchClipboard() {
if (amberReq && amberDeliver(txt)) return;
} catch {} // no permission: the paste box is the way in
};
- const onVis = () => { if (document.visibilityState === 'visible') setTimeout(read, 250); };
+ const onVis = () => {
+ if (document.visibilityState === 'hidden') { if (amberReq) amberReq.hidden = true; return; } // the signer came up
+ setTimeout(read, 250);
+ };
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onVis);
const iv = setInterval(read, 800);
@@ -395,6 +398,35 @@ function amberLaunch(url) {
if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') { window.__coinosAmberLaunch(url); return; } // tests
window.location.href = url;
}
+// Chrome lets a page open another app only on the heels of a tap: without
+// one the `nostrsigner:` navigation is dropped with a console warning the
+// page never sees ("Navigation is blocked"). The first request rides the
+// Sign-in tap; the ones the login fires once an answer is back (decrypt the
+// backup, sign the relay list…) have no tap behind them, so they wait for
+// one — the card shows an Open button — instead of spinning forever. A
+// launch that leaves the page in front is taken as blocked too.
+export function amberOpen() {
+ const r = amberReq;
+ if (!r) return false;
+ r.needsTap = false;
+ r.hidden = false;
+ clearTimeout(r.timer);
+ r.timer = setTimeout(() => amberCancel('signer did not answer'), r.timeoutMs);
+ amberChanged();
+ amberLaunch(r.url);
+ if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') return true; // tests: no app to come up
+ setTimeout(() => {
+ if (amberReq !== r || r.hidden || typeof document === 'undefined' || document.visibilityState !== 'visible') return;
+ r.needsTap = true;
+ amberChanged();
+ }, 2500);
+ return true;
+}
+const amberTapAvailable = () => {
+ if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') return true; // tests
+ const ua = typeof navigator !== 'undefined' ? navigator.userActivation : null;
+ return ua ? ua.isActive : true;
+};
// One trip at a time: the app fires several signatures at once right after a
// sign-in (the backup, the relay list, the follows), and Amber can only be
// asked for one; the rest wait their turn in this chain.
@@ -404,11 +436,11 @@ function amberRequest(type, payload, params = {}, { timeoutMs = 180_000 } = {})
if (params.pubkey) q.set('pubkey', params.pubkey);
const url = 'nostrsigner:' + encodeURIComponent(payload || '') + '?' + q.toString();
const run = () => new Promise((resolve, reject) => {
- amberReq = { type, payload: payload || '', ts: Date.now(), resolve, reject,
+ amberReq = { type, payload: payload || '', url, ts: Date.now(), resolve, reject, timeoutMs,
timer: setTimeout(() => amberCancel('signer did not answer'), timeoutMs) };
amberWatch = amberWatchClipboard();
- amberChanged();
- amberLaunch(url);
+ if (amberTapAvailable()) amberOpen();
+ else { amberReq.needsTap = true; amberChanged(); }
});
const p = amberQueue.then(run, run);
amberQueue = p.catch(() => {});
diff --git a/src/nostr.js b/src/nostr.js
index b76e865..f0b47d1 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';
@@ -185,7 +186,12 @@ pool.ensureRelay = async (url, params) => {
relay._coinosRL = true;
const notice = relay.onnotice;
relay.onnotice = (msg) => {
- if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg));
+ // Primal's "too many concurrent REQs" is advice, not a refusal: every
+ // REQ past its twenty is answered all the same (measured: 60 at once,
+ // 60 EOSEs, 12 notices). Cooling it off for 30 s at every boot — when
+ // the relay lists, profiles and the feed all ask at once — only lost
+ // us its answers. A CLOSED with that reason still counts (below).
+ if (RATE_LIMITED.test(String(msg)) && !/concurrent/i.test(String(msg))) markRelayRateLimited(relay.url, String(msg));
try { notice?.call(relay, msg); } catch {}
};
const subscribe = relay.subscribe.bind(relay);
@@ -283,6 +289,54 @@ export function resetRelay(url) {
try { r.close(); } catch {}
pool.relays.delete(n);
}
+// Back from a freeze (a phone's backgrounded tab: timers stopped, the
+// network's idea of every socket long gone), the pool's sockets still read
+// OPEN, and anything sent on them vanishes. Left to itself the library
+// notices one in up to 29 s (its ping) + 20 s (the ping's timeout), then
+// waits 10 s more before redialing — a minute of a feed that won't catch up
+// ("no new notes since 11:35?", Adam, 2026-11-01). So on resume every open
+// socket is asked one tiny REQ; one that answers within the deadline is
+// fine, one that doesn't is closed and redialed now, its subscriptions
+// refired by the library with `since` set to the last event each saw. A
+// relay already waiting out a reconnect backoff is dialed now too.
+const WAKE_PING_MS = 2500;
+const DUMMY_ID = 'a'.repeat(64);
+function pingRelay(r, ms) {
+ return new Promise((resolve) => {
+ let done = false;
+ const fin = (ok) => { if (done) return; done = true; clearTimeout(t); resolve(ok); };
+ const t = setTimeout(() => fin(false), ms);
+ try {
+ const sub = r.subscribe([{ ids: [DUMMY_ID], limit: 0 }], {
+ label: '<wake-ping>',
+ oneose: () => { fin(true); try { sub.close(); } catch {} },
+ onclose: () => fin(true), // a relay refusing the REQ is a relay that answers
+ eoseTimeout: ms + 1000, // ours fires first: the library's would count as an EOSE
+ });
+ } catch { fin(false); }
+ });
+}
+function redialRelay(r, why) {
+ console.warn('nostr: ' + r.url + ' ' + why + ' — redialing');
+ // the dead socket's close would reach the library long after (a closing
+ // handshake nobody answers), and undo the new connection: detached first
+ const ws = r.ws;
+ try { if (ws) { ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; ws.close(); } } catch {}
+ try { r.handleHardClose(why); } catch {} // the library's own path: subscriptions kept, a reconnect queued
+ if (r.reconnectTimeoutHandle) { clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined; }
+ r.connect().catch(() => {}); // now, not after the backoff
+}
+export async function wakeRelays(ms = WAKE_PING_MS) {
+ const checks = [];
+ for (const r of pool.relays.values()) {
+ if (r.connected && r.ws && r.ws.readyState === 1) checks.push(pingRelay(r, ms).then((ok) => { if (!ok) redialRelay(r, 'silent after resume'); }));
+ else if (!r.connectionPromise && r.reconnectTimeoutHandle) {
+ clearTimeout(r.reconnectTimeoutHandle); r.reconnectTimeoutHandle = undefined;
+ r.connect().catch(() => {});
+ }
+ }
+ await Promise.all(checks);
+}
// the relays a subscription on this list actually dials (sick ones skipped)
export const liveRelayList = (list) => liveRelays(list);
// ...and the ones a one-shot read goes to (rate-limited ones skipped too)
@@ -365,6 +419,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 {
@@ -404,7 +551,7 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) {
});
} catch {} finally { await Promise.all(pending); }
}
-export async function publishOn(relays, evt, { onauth = null } = {}) {
+export async function publishOn(relays, evt, { onauth = null, onrefused = null } = {}) {
try {
const live = liveRelays(relays);
// A rejected publish is a relay refusing the event (policy, rate limit,
@@ -426,7 +573,12 @@ export async function publishOn(relays, evt, { onauth = null } = {}) {
// "relay connection errored/closed" means the socket never came up —
// that's a sick relay, not a rejected event.
if (/connect|closed|errored|timeout|network/i.test(msg)) markRelaySick(live[i], msg);
- else console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
+ else {
+ console.warn(`nostr: publish kind ${evt.kind} refused by ${live[i]}:`, msg);
+ // the relay said no to this event (policy, a rate limit, a ban): a
+ // caller can stop asking it
+ if (onrefused) try { onrefused(live[i], msg); } catch {}
+ }
return false;
}));
// The first acceptance IS delivery — one durable copy is enough, and
diff --git a/src/nwc-bg.js b/src/nwc-bg.js
index 92786c0..9e7a303 100644
--- a/src/nwc-bg.js
+++ b/src/nwc-bg.js
@@ -57,6 +57,62 @@ export async function saveBg(walletKey, rec) {
export async function clearBg(walletKey) {
return tx('readwrite', (s) => { s.delete(walletKey); });
}
+
+// The worker's diary: what it did while the app was closed — each push, the
+// steps of a payment, every notification shown or refused. A phone stops a
+// worker without a trace, and the diary's last line says where it got to.
+// Settings → Notifications → Copy hands it to whoever is looking into it.
+// Appended line by line (a worker that dies mid-push keeps what it wrote),
+// one writer at a time, a few hundred lines kept.
+const LOG_KEY = '__log';
+const LOG_MAX = 400;
+let logChain = Promise.resolve();
+export function appendBgLog(line) {
+ logChain = logChain.then(() => tx('readwrite', async (s) => {
+ const cur = (await get(s, LOG_KEY)) || [];
+ cur.push(line);
+ s.put(cur.length > LOG_MAX ? cur.slice(-LOG_MAX) : cur, LOG_KEY);
+ })).catch(() => {});
+ return logChain;
+}
+export async function readBgLog() {
+ try { return (await tx('readonly', (s) => get(s, LOG_KEY))) || []; } catch { return []; }
+}
+export async function clearBgLog() {
+ return tx('readwrite', (s) => { s.delete(LOG_KEY); });
+}
+// A lease on the worker's wallet work, kept in this store. Two pushes seconds
+// apart ran side by side on iOS, each in its own worker (two "worker started"
+// lines), and nothing kept them apart: both finished one send, and the
+// second, over a stale copy, marked a paid send 'revoking'. The store is
+// the one thing both see, and a readwrite transaction is atomic across them.
+// A lease outlives a stopped worker only for its ttl.
+export async function takeLease(name, { ttl = 12_000, wait = 5_000 } = {}) {
+ const key = '__lease:' + name;
+ const me = Math.random().toString(36).slice(2);
+ const start = Date.now();
+ for (;;) {
+ let got = false;
+ try {
+ got = await tx('readwrite', async (s) => {
+ const cur = await get(s, key);
+ if (cur && cur.until > Date.now() && cur.owner !== me) return false;
+ s.put({ owner: me, until: Date.now() + ttl }, key);
+ return true;
+ });
+ } catch { got = true; } // no store, no lease: run
+ if (got) break;
+ if (Date.now() - start > wait) break; // a holder that overstays: go anyway
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ const waited = Date.now() - start;
+ const release = () => tx('readwrite', async (s) => {
+ const cur = await get(s, key);
+ if (cur && cur.owner === me) s.delete(key);
+ }).catch(() => {});
+ return { waited, release };
+}
+
// The service worker doesn't know wallet cache keys — it finds the right
// record by the connection's service pubkey across all of them.
export async function allBgs() {
@@ -67,7 +123,8 @@ export async function allBgs() {
cur.onsuccess = () => {
const c = cur.result;
if (!c) return res(out);
- out.push({ walletKey: c.key, rec: c.value });
+ // the worker's diary shares the store under a reserved key
+ if (!(typeof c.key === 'string' && c.key.startsWith('__'))) out.push({ walletKey: c.key, rec: c.value });
c.continue();
};
cur.onerror = () => rej(cur.error);
@@ -76,22 +133,34 @@ export async function allBgs() {
}
// How many upcoming change/refund indices travel with the record: the worker
-// cannot derive keys, so its allocations must land inside this window.
-export const BG_KEY_LOOKAHEAD = 25;
+// cannot derive keys, so its allocations must land inside this window. Each
+// Lightning send burns a change and an HTLC index, so 25 keys lasted PullCap
+// about 19 hourly payments (2026-10-26, "key window exhausted" after a day
+// with the app closed). 250 keys are 16 KB in the record and cover weeks of
+// hourly payments, or years of monthly ones; the app tops the window up
+// every time it writes the mirror.
+export const BG_KEY_LOOKAHEAD = 250;
+// ...and the receive preimages a closed wallet can mint invoices with
+export const BG_RECV_LOOKAHEAD = 60;
// { ark } endpoints, the manager state to mirror, and a keyFor(chain, index)
// derive function (the caller owns the account node — it never enters this
// module). Chain 3 covers coins + change, chain 4/0 the mailbox, chain 5 a
// window of receive preimages — everything the worker needs to pay AND to
// mint invoices while the app is closed.
-export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw }) {
+export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw, custody }) {
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 = {};
- for (let i = 0; i < 15; i++) {
+ for (let i = 0; i < BG_RECV_LOOKAHEAD; i++) {
const n = (mgrState.nextLnRecvIndex || 0) + i;
keys5[String(n)] = keyFor(5, n);
}
@@ -107,9 +176,17 @@ 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 custody tier ('custodial' | 'self' | 'early'): whether the worker
+ // registers renewal orders for bare coins, and how far ahead
+ custody: custody || 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,
+ id: c.id, name: c.name || '', servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
+ 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-lock.js b/src/nwc-lock.js
index c4fec08..bd8586a 100644
--- a/src/nwc-lock.js
+++ b/src/nwc-lock.js
@@ -7,6 +7,24 @@
// the sats had gone. Web Locks are shared by every same-origin context, so
// the first to claim a request's lock answers it and the rest stay quiet.
// Where locks are unavailable (tests, ancient engines) the caller just runs.
+// The worker's wallet work, one push at a time. Every push handler builds
+// its own manager over the one shared mirror, and two of them driving the
+// same send at once would sign it twice (a client's retry lands seconds
+// after its lookup). A waiting push keeps its waitUntil and reads the mirror
+// fresh once it gets the lock.
+// The Web Lock alone didn't hold on iOS (two workers at once, neither
+// waited), so the lease in the worker's store (nwc-bg.js) is taken inside it.
+export async function withWorkLock(fn, { lease = null } = {}) {
+ const locks = typeof navigator !== 'undefined' && navigator.locks;
+ const run = async () => {
+ if (!lease) return fn(0);
+ const l = await lease();
+ try { return await fn(l.waited); } finally { await l.release(); }
+ };
+ if (!locks) return run();
+ return locks.request('nwc-bg-work', run);
+}
+
export async function withRequestLock(id, fn, held = () => undefined) {
const locks = typeof navigator !== 'undefined' && navigator.locks;
if (!locks || !id) return fn();
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index 27699e7..12101f5 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -20,8 +20,18 @@ 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 { renewPolicy } from './ark/manager.js';
const MAX_AGE_SEC = 120;
+// A request the worker can't act on (keys or coins short in the mirror, an
+// old mirror) wakes the user — and, unless someone answers first, tells the
+// other app so (PullCap read the silence as "no answer" and suspended the
+// subscription; with an error it can tell the payer their wallet is waiting
+// for them). The notifier holds the reply this long before sending it.
+const DECLINE_AFTER_MS = 20_000;
+const FAIL_AFTER_MS = 5_000; // the notifier's shortest hold
+const WAIT_MSG = 'Coinos is waiting for its owner to open the app and approve. Look this request up again later.';
const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin
const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
@@ -59,17 +69,93 @@ export function shimAccount(rec) {
return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) };
}
-export async function bgManager(rec, walletKey, saveFn) {
+export async function bgManager(rec, walletKey, saveFn, log = null) {
+ // the diary follows each send's steps through the mirror's saves: a worker
+ // the phone stops mid-payment leaves its last step on record
+ const steps = new Map(((rec.mgr && rec.mgr.actions) || []).filter((a) => a.type === 'ln-pay').map((a) => [a.id, a.step]));
const storage = {
load: () => rec.mgr,
- save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); },
+ save: (s) => {
+ rec.mgr = s;
+ if (log) {
+ for (const a of s.actions || []) {
+ if (a.type !== 'ln-pay' || steps.get(a.id) === a.step) continue;
+ steps.set(a.id, a.step);
+ log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} → ${a.step}${a.error ? ': ' + a.error : ''}`);
+ }
+ }
+ Promise.resolve(saveFn(walletKey, rec)).catch(() => {});
+ },
};
return new ArkManager({
account: shimAccount(rec), storage,
arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
+ honorsExpired: !!rec.ark.honorsExpired,
+ lnOldestFirst: () => !!rec.custody && rec.custody !== 'custodial',
}).init();
}
+// ---- sends in flight across wakes ------------------------------------------
+// The phone stops a worker without warning: between reserving a coin and the
+// ASP's cosign, or between the ASP paying and our reply. The action stays in
+// the mirror at its last step, its coin reserved, and only the app used to
+// finish it. These let the worker finish it on a later wake instead.
+const pendingLnPays = (rec) => ((rec.mgr && rec.mgr.actions) || [])
+ .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step));
+// which connection a send belongs to, kept beside the mirror: the action is
+// the manager's and knows nothing of NWC
+function notePending(rec, hash, conn) {
+ rec.pendingPays = (rec.pendingPays || []).filter((p) => p.hash !== hash).slice(-50);
+ rec.pendingPays.push({ hash, connId: conn.id, ts: Date.now() });
+}
+function forgetPending(rec, hash) {
+ if (rec.pendingPays) rec.pendingPays = rec.pendingPays.filter((p) => p.hash !== hash);
+}
+function connOf(rec, a) {
+ const p = (rec.pendingPays || []).find((x) => x.hash === a.paymentHash);
+ return (p && (rec.connections || []).find((c) => c.id === p.connId)) || null;
+}
+// A settled send goes on the worker's spend log once; the app absorbs it
+// into budgets and history on next open.
+function recordBgSpend(rec, a, conn, fallback = {}) {
+ const hash = a.paymentHash || fallback.paymentHash;
+ forgetPending(rec, hash);
+ rec.spends = rec.spends || [];
+ if (hash && rec.spends.some((s) => s.paymentHash === hash)) return;
+ rec.spends.push({
+ connId: conn ? conn.id : null, amountSat: a.amountSat || fallback.amountSat, feeSat: a.feeSat || 0,
+ paymentHash: hash, preimage: a.preimage, invoice: a.invoice || fallback.invoice, ts: Date.now(),
+ });
+ if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
+}
+// Money left while the app was closed — that deserves a heads-up, unless
+// the user turned "payments sent" notifications off in Settings.
+function sentNotice(rec, a, conn, fallbackSat = 0) {
+ if (rec.prefs && rec.prefs.paySent === false) return true;
+ const paidSat = a.amountSat || fallbackSat;
+ const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : '';
+ return {
+ notify: {
+ title: 'Payment sent', tag: 'nwc-sent',
+ body: `-${paidSat.toLocaleString()} sats${feeNote} via ${(conn && conn.name) || 'a connected app'}`,
+ },
+ };
+}
+// Drive a Lightning send on until it settles, fails, or the budget is gone.
+// Every step is held to the deadline: a call to the ASP that hangs must not
+// carry the worker past its budget (iOS stops it, mid-line, with no trace).
+async function finishLnPay(mgr, id, deadline) {
+ const by = (p) => Promise.race([p, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
+ let a = await by(mgr.awaitLnPay(id, Math.max(500, deadline - Date.now())).catch(() => null));
+ a = mgr.lnAction(id);
+ while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline - 1500) {
+ await new Promise((r) => setTimeout(r, 1000));
+ await by(mgr.driveLn(id).catch(() => {}));
+ a = mgr.lnAction(id);
+ }
+ return a;
+}
+
// A CLINK offer request (kind 21001): mint an invoice against the mirrored
// wallet and reply. The payment itself arrives later over the ASP's hold
// invoice — the app claims it on next open, so we also ask the caller to
@@ -125,11 +211,14 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log
// left to another device); false means "wake the user instead".
export async function respondFromBg(data, {
notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {},
- errGraceMs = 3000,
+ errGraceMs = 3000, deadline: until = null,
} = {}) {
const ev = data && data.event;
if (!ev || !ev.id || !ev.pubkey) return false;
- if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
+ // Older than the request window (a push that sat at APNs while the phone
+ // was out of reach): the other app has moved on — nothing to answer, and
+ // no "Payment request" to show for it. The wake still tends what's in flight.
+ if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) { log(`request ${String(ev.id).slice(0, 8)} is ${nowSec() - ev.created_at} s old — ignored`); return true; }
const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
if (!target) return false;
@@ -160,11 +249,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 +258,34 @@ 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 || {};
+ // Wake the user, out loud: the wait reply is left with the notifier, which
+ // sends it only if nobody (a sibling device, the woken user) answers in
+ // time — so it can never land on top of a real payment.
+ const declineLater = async (why) => {
+ log(why + ' — waking the user; the app is told to wait');
+ try {
+ const evt = await buildReply(conn, ev, scheme, errRes(method || 'error', 'OTHER', WAIT_MSG));
+ await fetchFn(`${notifier}/decline`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, afterMs: DECLINE_AFTER_MS }),
+ });
+ } catch (e) { log('could not leave the wait reply: ' + (e && e.message)); }
+ return false;
+ };
+ // the push's budget (on iOS about ten seconds from the push, not thirty)
+ const deadline = until || Date.now() + PAY_WAIT_MS;
+ let lookupNotice = null;
+ // 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 +316,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;
@@ -215,9 +327,9 @@ export async function respondFromBg(data, {
if (method === 'make_invoice') {
const sat = Math.floor((params.amount || 0) / 1000);
if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; }
- if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user
- if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted
- if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted
+ if (!rec.keys5 || !rec.key4 || !rec.mgr) return declineLater('old mirror, no receive keys');
+ if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return declineLater('preimage window exhausted');
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted');
let a;
try {
const mgr = await bgManager(rec, walletKey, saveFn);
@@ -243,28 +355,60 @@ export async function respondFromBg(data, {
await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required'));
return true;
}
- if (!rec.mgr) return false;
const q = { paymentHash: params.payment_hash, invoice: params.invoice };
- // read straight off the mirror; the ASP is only needed to claim
- const view = Object.assign(Object.create(ArkManager.prototype), { state: rec.mgr, account: shimAccount(rec) });
- let tx = view.lnLookup(q);
- const a = tx && tx.state === 'pending' && tx.type === 'incoming'
- && (rec.mgr.actions || []).find((x) => x.type === 'ln-recv' && x.paymentHash === tx.payment_hash);
+ // read straight off the mirror; the ASP is only needed to claim. Every
+ // copy of the connection is asked (the freshest may be another network's
+ // mirror: PullCap's lookups of a send in flight came back "not found").
+ let tx = null;
+ for (const m of matches) {
+ if (!m.rec.mgr) continue;
+ const view = Object.assign(Object.create(ArkManager.prototype), { state: m.rec.mgr, account: shimAccount(m.rec) });
+ tx = view.lnLookup(q);
+ if (tx) { ({ walletKey, rec, conn } = m); break; }
+ }
+ if (!tx && !rec.mgr) return declineLater('old mirror, no state to look up');
+ // a payment of ours still on its way (incoming: claim it; outgoing: a
+ // send an earlier wake began — drive it on, so the lookup itself can
+ // answer 'settled' with the preimage instead of a second pay request)
+ const a = tx && tx.state === 'pending'
+ && (rec.mgr.actions || []).find((x) => x.type === (tx.type === 'incoming' ? 'ln-recv' : 'ln-pay') && x.paymentHash === tx.payment_hash
+ && !['done', 'failed'].includes(x.step));
if (a) {
try {
- const mgr = await bgManager(rec, walletKey, saveFn);
- await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, PAY_WAIT_MS))]);
+ const mgr = await bgManager(rec, walletKey, saveFn, log);
+ if (a.type === 'ln-pay') {
+ const r = await finishLnPay(mgr, a.id, Math.min(deadline, Date.now() + 6000));
+ if (r && r.step === 'done') {
+ const c = connOf(rec, r) || conn;
+ recordBgSpend(rec, r, c);
+ log(`finished a ${r.amountSat} sat send from an earlier wake`);
+ const n = sentNotice(rec, r, c);
+ if (n !== true) lookupNotice = n.notify;
+ } else if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
+ await saveFn(walletKey, rec);
+ } else {
+ await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
+ }
tx = mgr.lnLookup(q);
- if (tx.state === 'settled') log(`received ${tx.amount / 1000} sat while closed`);
+ if (tx.state === 'settled' && tx.type === 'incoming') log(`received ${tx.amount / 1000} sat while closed`);
} catch (e) { log('ark init failed: ' + e.message); }
}
await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found'));
- return true;
+ log(`lookup: ${tx ? tx.type + ' ' + tx.state : 'not found'}`);
+ return lookupNotice ? { notify: lookupNotice } : true;
}
if (method === 'get_balance') {
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,51 +453,126 @@ 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
-
- // The ASP's own Lightning send, over the mirrored state. Change and HTLC
- // outputs allocate from nextKeyIndex, which must stay inside the key
- // window the app pre-derived; if it ran out, wake the user instead.
- if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; }
- let mgr;
- try {
- mgr = await bgManager(rec, walletKey, saveFn);
- } catch (e) { log('ark init failed: ' + e.message); return false; }
-
- // A local failure carries no urgency — wait out the grace period and
- // re-check before publishing, so a sibling device's slower SUCCESS reply
- // wins the client's eyes instead of being shouted over.
+ // A send this worker began on an earlier wake and never finished (PullCap's
+ // night of 2026-10-23: three hours of fifteen). Its coin sat reserved in
+ // the mirror, so a fresh attempt at the SAME invoice was refused as
+ // "already paid or is being paid" — which the client read as paid, hours
+ // before the money moved — and one at any OTHER invoice found no spendable
+ // coin and woke the user. Finish what was begun first: the same invoice
+ // gets that payment's outcome, another invoice gets the coin back.
+ const stuck = pendingLnPays(rec);
+ const same = stuck.find((a) => a.paymentHash === dec.paymentHash)
+ || (rec.mgr.actions || []).find((a) => a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step === 'done');
+ let mgr = null;
+ let mgrP = null;
+ const ready = async () => mgr || (mgr = await (mgrP || (mgrP = bgManager(rec, walletKey, saveFn, log))));
+ // the manager's start (the ASP's info, the chain tip) runs while the
+ // notifier is asked whether anyone else answered
+ ready().catch(() => {});
+ // A local failure carries no urgency — a sibling device's slower SUCCESS
+ // reply must win the client's eyes instead of being shouted over. The
+ // error is left with the notifier, which sends it only if nobody answers
+ // in FAIL_AFTER_MS: waiting here instead ran past iOS's ten seconds after
+ // a failed send, and the other app heard nothing at all (PullCap,
+ // 2026-10-29). Waiting here is the fallback when the notifier won't take it.
const failQuietly = async (message) => {
+ const code = /insufficient/i.test(message || '') ? 'INSUFFICIENT_BALANCE' : 'PAYMENT_FAILED';
+ try {
+ const evt = await buildReply(conn, ev, scheme, errRes('pay_invoice', code, message));
+ const r = await fetchFn(`${notifier}/decline`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt, afterMs: FAIL_AFTER_MS }),
+ });
+ if (r.ok) { log(`payment failed (${message}) — ${code} goes out unless someone answers first`); return true; }
+ } catch {}
await new Promise((r) => setTimeout(r, errGraceMs));
if (!(await answered())) {
- await publish(errRes('pay_invoice', 'INTERNAL', message)).catch(() => {});
+ await publish(errRes('pay_invoice', code, message)).catch(() => {});
}
return true;
};
+ const settled = async (a, c, { record = true } = {}) => {
+ if (record) recordBgSpend(rec, a, c, { paymentHash: dec.paymentHash, amountSat: dec.amountSat, invoice });
+ // the mirror and the reply at once: every second counts on iOS, and the
+ // "Payment sent" line comes after both
+ await Promise.all([
+ saveFn(walletKey, rec),
+ publish({ result_type: 'pay_invoice', result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 } }).then(() => log('reply sent')),
+ ]);
+ return sentNotice(rec, a, c, dec.amountSat);
+ };
+ if (same) {
+ if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
+ log(`a send for this invoice is already ${same.step} — finishing it`);
+ const wasDone = same.step === 'done';
+ let a = same;
+ if (!wasDone) {
+ try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; }
+ a = await finishLnPay(mgr, same.id, deadline);
+ }
+ // settled earlier (by the app, perhaps): its preimage again, nothing new
+ // on the spend log
+ if (a && a.step === 'done') return settled(a, conn, { record: !wasDone });
+ if (a && a.step === 'failed') { forgetPending(rec, dec.paymentHash); return failQuietly(a.error || 'payment failed'); }
+ log('that send is still in flight — waking the user to finish it');
+ return false;
+ }
+ const notices = [];
+ // Only when they hold the coins this payment needs: otherwise the answer
+ // comes first and the wake's tending finishes them after.
+ if (stuck.length && spendableSat(rec) < dec.amountSat) {
+ try {
+ await ready();
+ for (const a of stuck) {
+ if (Date.now() > deadline - 4000) break;
+ const r = await finishLnPay(mgr, a.id, Math.min(deadline - 3000, Date.now() + 5000));
+ if (!r || r.step !== 'done') {
+ log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`);
+ if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
+ continue;
+ }
+ const c = connOf(rec, r);
+ recordBgSpend(rec, r, c);
+ log(`finished a ${r.amountSat} sat send from an earlier wake`);
+ const n = sentNotice(rec, r, c);
+ if (n !== true) notices.push(n.notify);
+ }
+ await saveFn(walletKey, rec);
+ } catch (e) { log('ark init failed: ' + e.message); }
+ }
+ const withNotices = (out) => (notices.length && out ? { ...(out === true ? {} : out), notices } : out);
+
+ const spendable = spendableSat(rec);
+ if (spendable < dec.amountSat) return declineLater(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked`);
+
+ // The ASP's own Lightning send, over the mirrored state. Change and HTLC
+ // outputs allocate from nextKeyIndex, which must stay inside the key
+ // window the app pre-derived; if it ran out, wake the user instead.
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return declineLater('key window exhausted');
+ try { await ready(); } catch (e) { return declineLater('ark init failed: ' + e.message); }
+
if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
- const deadline = Date.now() + PAY_WAIT_MS;
+ // whose send this is, for the wake that finishes it should this one not
+ notePending(rec, dec.paymentHash, conn);
+ await saveFn(walletKey, rec);
+ log(`paying ${dec.amountSat} sat`);
let a = null;
for (let attempt = 0; ; attempt++) {
let id;
try {
id = await mgr.payLnInvoice(invoice);
} catch (e) {
+ forgetPending(rec, dec.paymentHash);
return failQuietly(e.message);
}
- a = await mgr.awaitLnPay(id, Math.max(1000, deadline - Date.now())).catch(() => mgr.lnAction(id));
- while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) {
- await new Promise((r) => setTimeout(r, 1500));
- await mgr.driveLn(id).catch(() => {});
- a = mgr.lnAction(id);
- }
+ a = await finishLnPay(mgr, id, deadline);
// A ghost coin (spent elsewhere, this mirror never learned) dies on the
// launchpad in milliseconds. Reconcile against the server — flipping the
// ghost — and retry once from live coins; the budget has barely started.
@@ -368,6 +587,7 @@ export async function respondFromBg(data, {
// Another device of this wallet started this same payment first. Its
// outcome is ours: wait on it, and only speak if the payer stays quiet.
log('a sibling device is paying this invoice — waiting on its outcome');
+ forgetPending(rec, dec.paymentHash);
const out = await mgr.lnOutcome(dec.paymentHash, Math.max(3000, deadline - Date.now()));
if (out.status === 'failed') return failQuietly(a.error);
if (out.status !== 'success') { log('sibling payment still pending — leaving it to them'); return true; }
@@ -378,36 +598,103 @@ export async function respondFromBg(data, {
return true;
}
if (!a || a.step === 'failed') {
+ forgetPending(rec, dec.paymentHash);
return failQuietly((a && a.error) || 'payment failed');
}
if (a.step !== 'done') {
- // still in flight as our budget runs out: the app resumes it from the
- // mirrored state on next open — wake the user rather than guess.
+ // still in flight as our budget runs out: the next wake (or the app, on
+ // open) finishes it — wake the user rather than guess.
log('payment still pending at SW deadline');
return false;
}
-
- rec.spends = rec.spends || [];
- rec.spends.push({
- connId: conn.id, amountSat: a.amountSat || dec.amountSat, feeSat: a.feeSat || 0,
- paymentHash: dec.paymentHash, preimage: a.preimage, invoice, ts: Date.now(),
- });
- if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
- await saveFn(walletKey, rec);
- await publish({
- result_type: 'pay_invoice',
- result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 },
- });
log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`);
- // Money left while the app was closed — that deserves a heads-up, unless
- // the user turned "payments sent" notifications off in Settings.
- if (rec.prefs && rec.prefs.paySent === false) return true;
- const paidSat = a.amountSat || dec.amountSat;
- const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : '';
- return {
- notify: {
- title: 'Payment sent', tag: 'nwc-sent',
- body: `-${paidSat.toLocaleString()} sats${feeNote} via ${conn.name || 'a connected app'}`,
- },
- };
+ return withNotices(await settled(a, conn));
+}
+
+// Sends begun on an earlier wake and never finished (see pay_invoice): any
+// wake drives each on, within its budget, and hands back the "Payment sent"
+// lines for the ones that settle. Without this a stuck send waited for the
+// app to open, its coin reserved all the while.
+export async function tendLnPays({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000 } = {}) {
+ const notices = [];
+ for (const { walletKey, rec } of await recordsFn()) {
+ if (!rec || rec.v < 3 || !rec.mgr || !rec.ark) continue;
+ const stuck = pendingLnPays(rec);
+ if (!stuck.length || Date.now() > deadline - 3000) continue;
+ try {
+ const mgr = await Promise.race([bgManager(rec, walletKey, saveFn, log),
+ new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), Math.max(0, deadline - Date.now())))]);
+ for (const a of stuck) {
+ if (Date.now() > deadline - 3000) break;
+ const r = await finishLnPay(mgr, a.id, deadline);
+ if (!r || r.step !== 'done') {
+ log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`);
+ if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
+ continue;
+ }
+ const c = connOf(rec, r);
+ recordBgSpend(rec, r, c);
+ log(`finished a ${r.amountSat} sat send from an earlier wake`);
+ const n = sentNotice(rec, r, c);
+ if (n !== true) notices.push(n.notify);
+ }
+ await saveFn(walletKey, rec);
+ } catch (e) { log('earlier sends: ' + e.message); }
+ }
+ return notices;
+}
+
+// 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).
+ // Only under a custody tier that renews at all — the default lets coins
+ // run past their timelock in coinos's hands, by choice, and pays nothing.
+ const policy = renewPolicy(rec.custody || 'custodial', rec.ark.network);
+ let room = 0;
+ while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++;
+ if (room && policy) {
+ const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; });
+ if (got) { n += got; room -= got; log(`registered ${got} renewal order(s) while closed`); }
+ }
+ // Under every tier: a coin near the server's depth cap must be
+ // flattened to stay spendable (the base fee is waived for it).
+ if (room > 0) {
+ const got = await bounded(mgr.ensureDeepRefreshes(room)).catch((e) => { log('deep refresh order: ' + e.message); return 0; });
+ if (got) { n += got; log(`registered ${got} deep-coin 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..cef3663 100644
--- a/src/style.css
+++ b/src/style.css
@@ -30,6 +30,12 @@
--accent-fg: #ffffff; /* text on accent */
--accent-ink: #000000;
--accent-soft: #ececef;
+ /* a button's fill (primary, chosen chip or tab): the accent in light; in
+ dark a lifted grey, not a white slab (Adam, 2026-10-18: "Probably
+ shouldn't have white buttons in dark mode") */
+ --fill: var(--accent);
+ --fill-hover: var(--accent-hover);
+ --fill-fg: var(--accent-fg);
--green: #14935a;
--green-soft: #e7f6ee;
--green-line: #c4e7d4;
@@ -65,6 +71,9 @@
--accent-fg: #0f1216; /* text on accent */
--accent-ink: #e7e9ee;
--accent-soft: #262b33;
+ --fill: #353b45;
+ --fill-hover: #404753;
+ --fill-fg: #f3f5f8;
--green: #2dbd80;
--green-soft: #14271d;
--green-line: #235340;
@@ -92,6 +101,12 @@ html {
one last time" after a switch. Stable gutter = no reflow when content
grows. */
scrollbar-gutter: stable;
+ /* Nothing ever scrolls the page sideways. Anything wider than the screen
+ (a strip of chips, a stray wide row) would otherwise let an iPhone
+ pinch the page out and pan to it. (On the root element, not body: a
+ body that clips becomes the scroll container a sticky rail measures
+ against, and the rail would stop sticking.) */
+ overflow-x: hidden;
}
html, body {
@@ -224,8 +239,8 @@ button, .btn {
button:hover { background: var(--surface2); }
button:active { transform: translateY(1px); }
button:disabled { opacity: .5; cursor: not-allowed; }
-.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
-.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.btn-primary { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); }
+.btn-primary:hover { background: var(--fill-hover); border-color: var(--fill-hover); }
.btn-ghost { background: transparent; }
.btn-sm { padding: 7px 11px; font-size: 13px; border-radius: 9px; }
.btn-sm.on { border-color: var(--accent); color: var(--accent); }
@@ -236,7 +251,7 @@ button:disabled { opacity: .5; cursor: not-allowed; }
/* ---- inputs ---- */
label.field { display: block; }
.field .lab { font-size: 13px; font-weight: 600; color: var(--muted); margin-bottom: 6px; display: block; }
-input[type=text], input[type=search], input[type=password], input[type=number], textarea, select {
+input[type=text], input[type=search], input[type=password], input[type=number], input[type=datetime-local], textarea, select {
width: 100%; font: inherit; color: var(--ink); background: var(--paper);
border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px;
}
@@ -285,6 +300,12 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; padding: 9px 14px; background: var(--paper); }
.seg button.active { background: var(--accent-soft); color: var(--accent-ink); }
+/* The profile's Posts / Replies tabs sit right on white cards: a soft-grey
+ active tab beside a white one read as the WHITE one being selected (its
+ colour matched the posts under it). So these take the feed chips' look:
+ the chosen tab solid accent, the other one quiet (Adam, 2026-10-16). */
+.prof-tabs button { background: var(--surface2); color: var(--muted); }
+.prof-tabs button.active { background: var(--fill); color: var(--fill-fg); }
/* ---- address / qr ---- */
.qr { width: 220px; height: 220px; display: block; border-radius: 12px; border: 1px solid var(--line); }
@@ -338,6 +359,10 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
/* ---- lists (tx / utxo) ---- */
.list { display: flex; flex-direction: column; }
.item { display: flex; align-items: center; gap: 12px; padding: 13px 2px; border-top: 1px solid var(--line); }
+/* the middle column may shrink: a long name chip beside the label then
+ ellipsizes on one line instead of breaking mid-name and pushing the
+ amount about */
+.item > .grow { min-width: 0; }
.item:first-child { border-top: 0; }
.ico { width: 34px; height: 34px; border-radius: 9px; flex: none; display: grid; place-items: center; font-size: 16px; }
.ico.in { background: var(--green-soft); color: var(--green); }
@@ -345,7 +370,7 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
.amount-pos { color: var(--green); font-family: var(--mono); font-weight: 600; }
.amount-neg { color: var(--red); font-family: var(--mono); font-weight: 600; }
.amount { font-family: var(--mono); font-weight: 600; }
-.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; }
+.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; white-space: nowrap; flex: none; }
.tag.pending { background: var(--accent-soft); color: var(--accent-ink); }
.tag.conf { background: var(--green-soft); color: var(--green); }
@@ -714,7 +739,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.pos-big { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: -.5px; }
.pos-tips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.pos-tip { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; }
-.pos-tip.on { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
+.pos-tip.on { border-color: var(--fill); background: var(--fill); color: var(--fill-fg); }
.pos-tip.on .muted { color: inherit; opacity: .8; }
.pos-tip-label { font-size: 20px; font-weight: 600; }
.pos-total { font-size: 22px; font-weight: 600; margin-top: 4px; }
@@ -723,14 +748,12 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.pos-sale.faint { opacity: .55; }
.pos-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.pos-key { padding: 14px 0; font-size: 22px; font-weight: 600; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
-.pos-key:active { background: var(--accent); color: var(--accent-fg); }
+.pos-key:active { background: var(--fill); color: var(--fill-fg); }
.pos-key.del { font-weight: 400; }
/* the feeds row: pills that scroll sideways, the one on screen filled */
.feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.feed-chips::-webkit-scrollbar { display: none; }
/* with a mouse: a thin bar says the strip scrolls, and it drags (app.js) */
-/* grabbing the feed to scroll it (app.js) */
-.grab-scrolling, .grab-scrolling * { cursor: grabbing !important; user-select: none !important; }
@media (hover: hover) and (pointer: fine) {
.feed-chips { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; padding-bottom: 6px; cursor: grab; }
.feed-chips::-webkit-scrollbar { display: block; height: 6px; }
@@ -739,7 +762,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
.feed-chips.dragging .feed-chip { pointer-events: none; }
}
.feed-chip { flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface2); color: var(--muted); border-radius: 999px; padding: 5px 12px; font-size: 14px; line-height: 1.2; cursor: pointer; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; }
-.feed-chip.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
+.feed-chip.on { background: var(--fill); color: var(--fill-fg); border-color: var(--fill); }
.feed-chip.add { font-weight: 600; padding: 5px 11px; }
.note-act {
background: none; border: 0; padding: 4px 6px; margin: 0; line-height: 1;
@@ -828,11 +851,29 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
}
.lightbox-x:hover { background: rgba(255, 255, 255, .24); }
@keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } }
-.note-video { display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; border-radius: 10px; margin-top: 6px; background: #000; }
+.note-video { display: block; width: 100%; height: auto; max-height: var(--vid-cap); object-fit: contain; border-radius: 10px; margin-top: 6px; background: #000; }
/* a video plays by itself, muted, while it is on screen; the corner button
turns the sound on (see autoplay in messages.js) */
.note-video-box { position: relative; display: block; max-width: 100%; margin-top: 6px; }
.note-video-box .note-video { margin-top: 0; }
+/* the height a feed clip may take: the whole screen on a phone, three
+ quarters of it on a computer (a portrait clip on a 1920 screen ran off it) */
+:root { --vid-cap: 100vh; }
+@media (min-width: 900px) { :root { --vid-cap: 75vh; } }
+/* with a mouse: the big play/pause in the middle, while the pointer is over the clip */
+.vid-pp {
+ position: absolute; left: 50%; top: 50%; z-index: 2; width: 64px; height: 64px; margin: -32px 0 0 -32px; padding: 0;
+ border: 0; border-radius: 50%; background: rgba(18, 18, 18, .6); color: #fff; cursor: pointer;
+ display: none; align-items: center; justify-content: center;
+}
+.vid-pp:hover { background: rgba(18, 18, 18, .85); }
+.vid-pp-i { width: 0; height: 0; margin-left: 5px; border-style: solid; border-width: 13px 0 13px 22px; border-color: transparent transparent transparent #fff; }
+.vid-pp.playing .vid-pp-i { width: 20px; height: 24px; margin: 0; border-width: 0 7px; border-color: #fff; }
+.has-mouse .note-video-box:hover .vid-pp, .vid-pp:focus-visible { display: flex; }
+/* the moments bar is the clip's only seek bar: the middle button is its play
+ control on a touch screen too, up whenever the clip is paused */
+.note-video-box.own-bar .vid-pp:not(.playing) { display: flex; }
+.note-video-box:has(.vid-heavy) .vid-pp { display: none; }
.vid-sound {
position: absolute; top: 8px; left: 8px; z-index: 2; margin: 0; padding: 4px 10px; border: 0;
border-radius: 999px; background: rgba(18, 18, 18, .7); color: #fff; font-size: 13px; line-height: 1.3;
@@ -867,6 +908,71 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.addr-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; display: block; background: var(--line); }
.addr-title { font-weight: 650; font-size: 1.05em; line-height: 1.3; }
.addr-summary { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; }
+/* Live streams (NIP-53): the row of what's on above a feed, a profile's
+ "on now", a post's card, and the stream's own page (player over chat). */
+/* a face whose person is streaming now: a red ring, and LIVE under the bigger ones */
+.chat-avatar.live-ring { box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px #e0245e; position: relative; overflow: visible; }
+.chat-avatar.mini.live-ring { box-shadow: 0 0 0 1px var(--paper), 0 0 0 2.5px #e0245e; }
+.chat-avatar.live-ring:not(.mini)::after { content: 'LIVE'; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%);
+ padding: 0 4px; border-radius: 3px; font-size: 9px; font-weight: 700; line-height: 13px; letter-spacing: .04em;
+ color: #fff; background: #e0245e; box-shadow: 0 0 0 2px var(--paper); text-transform: none; }
+.live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px 10px; padding: 2px 0 4px; }
+.live-past-title { margin: 6px 0 0; font-size: 15px; }
+.live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 5px; vertical-align: 1px; }
+.live-tile { display: flex; flex-direction: column; gap: 5px; padding: 0; text-align: left;
+ background: none; border: 0; color: inherit; cursor: pointer; min-width: 0; }
+.live-cover { position: relative; width: 100%; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden;
+ background: var(--line) center / cover no-repeat; }
+.live-badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 700;
+ letter-spacing: .04em; color: #fff; background: #555; line-height: 1.5; flex-shrink: 0; }
+.live-badge.live { background: #e0245e; }
+.live-cover .live-badge { position: absolute; top: 6px; left: 6px; }
+.live-viewers { position: absolute; bottom: 6px; left: 6px; padding: 1px 6px; border-radius: 4px; font-size: 11px;
+ color: #fff; background: rgba(0, 0, 0, .6); }
+.live-tile-title { font-weight: 600; font-size: 14px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical; overflow: hidden; }
+.live-tile-host { align-items: center; min-width: 0; }
+.live-tile-host .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.live-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid #e0245e;
+ border-radius: 10px; background: none; color: inherit; cursor: pointer; text-align: left; min-width: 0; }
+.live-banner-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
+.live-card { flex-direction: row; align-items: center; gap: 10px; }
+.live-card .live-cover { flex: 0 0 120px; width: 120px; }
+.live-title { margin: 0; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.live-player { width: 100%; aspect-ratio: 16 / 9; background: #000; border-radius: 10px; overflow: hidden; }
+.live-frame { width: 100%; height: 100%; }
+.live-video { width: 100%; height: 100%; display: block; background: #000; }
+.live-still { border-radius: 0; height: 100%; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; }
+.live-rec-link { background: var(--paper); color: var(--ink); text-decoration: none; }
+.live-say { padding: 6px 12px; border-radius: 8px; color: #fff; background: rgba(0, 0, 0, .65); font-size: 14px; text-align: center; }
+/* the top padding is headroom for a hat on the first line's face: a scroller clips what rises out of it */
+.live-chat { display: flex; flex-direction: column; gap: 8px; max-height: 46vh; min-height: 120px; overflow-y: auto; overscroll-behavior: contain; padding-top: 12px; }
+.live-line { display: flex; gap: 8px; align-items: flex-start; min-width: 0; }
+.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; }
+.live-zap .live-line-body b { color: var(--accent); }
+.live-chat .chat-row { padding-right: 2px; }
+/* on phones the stream is the page, the way Nostrich lays one out: the
+ picture runs edge to edge and the chat under it is a plain full-width
+ list of lines, no bubbles — the card walls step outside #app's padding */
+@media (max-width: 520px) {
+ .live-page > .card {
+ margin-left: -16px; margin-right: -16px; padding: 10px 0 12px;
+ border-left: none; border-right: none; border-radius: 0; box-shadow: none;
+ }
+ .live-page > .live-chat-card { margin-top: 0; padding-top: 6px; border-top: none; }
+ .live-page > .card > :not(.live-player, .live-chat) { margin-left: 14px; margin-right: 14px; }
+ .live-page .live-player { border-radius: 0; }
+ .live-page .live-chat { padding: 10px 14px 0; max-height: 60vh; }
+ .live-page .live-title { font-size: 17px; }
+ .live-chat .chat-row { gap: 10px; }
+ .live-chat .chat-bubble { display: block; background: transparent; border: 0; border-radius: 0; padding: 0; font-size: 15px; }
+ .live-chat .chat-quote { margin-bottom: 2px; }
+}
+.bc-preview.mirror { transform: scaleX(-1); }
+.bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; }
+.go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); }
+.go-live-btn { display: inline-flex; align-items: center; gap: 8px; }
+.go-live-title { width: 100%; }
.article-title { font-size: 1.35em; font-weight: 700; line-height: 1.25; margin-bottom: 6px; white-space: normal; }
.article-h1, .article-h2, .article-h3 { font-weight: 650; line-height: 1.3; margin: 10px 0 2px; white-space: normal; }
.article-h1 { font-size: 1.2em; } .article-h2 { font-size: 1.1em; } .article-h3 { font-size: 1em; }
@@ -942,7 +1048,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
left: 50%; transform: translateX(-50%);
z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px;
- background: var(--accent); color: var(--accent-fg); border: none;
+ background: var(--fill); color: var(--fill-fg); border: none;
font-size: 16px; font-weight: 650; cursor: pointer;
touch-action: none; user-select: none; -webkit-user-select: none; /* it swipes away */
box-shadow: 0 3px 12px rgba(0, 0, 0, .28);
@@ -951,7 +1057,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
/* the generic button hover (a light surface) and press (a 1px drop, which
REPLACED the centring transform and threw the pill half its width to the
right, out from under the pointer, so the tap never landed) stay off it */
-.feed-new-pill:hover { background: var(--accent-hover); color: var(--accent-fg); }
+.feed-new-pill:hover { background: var(--fill-hover); color: var(--fill-fg); }
.feed-new-pill:active { transform: translate(-50%, 1px); }
/* the count bumps each time it changes: a fresh node per value replays it */
.feed-new-pill .n { display: inline-block; min-width: 1.1em; text-align: center; animation: pill-bump .32s cubic-bezier(.2,.9,.3,1.4); }
@@ -1113,7 +1219,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.profile-banner + .row .profile-avatar {
border: 3px solid var(--paper); box-sizing: content-box;
}
-.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; }
+.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; min-width: 0; max-width: 100%; }
+.zap-chip > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; }
/* A feed row is the one place a face is the point, not a label on a line —
Amethyst gives it 55dp; at our 30px it read as a bullet beside the post.
@@ -1154,6 +1261,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.chat-avatar.header-ava { width: 30px; height: 30px; font-size: 11px; }
.composer-options { margin-inline-start: auto; flex-shrink: 0; }
.compose-preview-toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--ink); }
+.post-actions button[aria-pressed="true"]:not(.compose-preview-toggle) { background: var(--accent-soft); color: var(--ink); }
+.sched-pick input { min-width: 0; }
/* the post composer: Preview and Cancel stand as tall as Post and the
attach square beside them, in the same type */
.post-actions .composer-options > button { padding: 11px 16px; font-size: inherit; border-radius: 11px; min-height: 44px; }
@@ -1177,7 +1286,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.hat-demo { position: relative; display: inline-flex; width: 34px; height: 34px; flex-shrink: 0; margin-top: 12px; }
.hat-demo-head { width: 100%; height: 100%; border-radius: 50%; background: var(--accent-soft); }
.hat-btn { white-space: nowrap; flex-shrink: 0; }
-.hat-btn.wearing { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
+.hat-btn.wearing { background: var(--fill); border-color: var(--fill); color: var(--fill-fg); }
.hat-btn.confirm { border-color: var(--green); color: var(--green); }
/* a small centered confirm popup over a dimmed page */
@@ -1247,7 +1356,7 @@ button, .btn { transition: filter .12s, background .15s, border-color .15s, box-
.chat-avatar.clickable:hover { transform: scale(1.07); }
.header-avatar .chat-avatar { transition: transform .15s ease; }
.header-avatar:hover .chat-avatar { transform: scale(1.08); }
-input[type=text], input[type=search], input[type=password], input[type=number], textarea, select { transition: border-color .15s, box-shadow .15s; }
+input[type=text], input[type=search], input[type=password], input[type=number], input[type=datetime-local], textarea, select { transition: border-color .15s, box-shadow .15s; }
.settings-nav::after { transition: transform .18s ease, color .18s; }
.settings-nav:hover::after { transform: translateX(3px); color: var(--ink); }
@@ -1455,7 +1564,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; }
@@ -1465,6 +1580,20 @@ body::after {
background: var(--surface2); overflow: hidden; cursor: pointer;
}
.media-tile.video { aspect-ratio: 9 / 16; }
+/* only wide clips in it: one to a row, the poster whole */
+.media-grid.wide { grid-template-columns: 1fr; gap: 6px; }
+.media-grid.wide .media-tile.video { aspect-ratio: 16 / 9; }
+/* every shape in it: justified rows. A tile starts at its proportions at
+ the row height and grows in proportion to them, so a row's tiles share one
+ height and fill the width; the filler takes the last row's slack instead
+ of stretching its few tiles. */
+.media-grid.justified { display: flex; flex-wrap: wrap; --row-h: 140px; }
+.media-grid.justified .media-tile { aspect-ratio: var(--ar); flex: var(--ar) 1 calc(var(--ar) * var(--row-h)); min-width: 0; }
+.media-grid.justified::after { content: ''; flex: 1000 1 0; }
+/* the picture fills the tile without sizing it: a frame-grab <video>'s own
+ 320px would widen its tile, and the row's height with it */
+.media-grid.justified .media-tile > img, .media-grid.justified .media-tile > video { position: absolute; inset: 0; }
+@media (max-width: 600px) { .media-grid.justified { --row-h: 100px; } }
.media-tile img, .media-tile video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.media-tile.broken { display: none; }
.media-tile-play {
@@ -1505,8 +1634,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 +1662,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;
@@ -1534,7 +1670,11 @@ 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; }
+/* yours: the heart goes red; the count stays white (a 🤣 of yours left only the number red) */
+.mp-like.on .i { 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 +1686,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 +1784,15 @@ 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; }
+/* out of sight (and out of reach: a tap there must not jump the clip) while
+ it plays; up when paused (.show), after a tap near the foot (.reveal), or held */
+.mp-seek.ready { visibility: visible; opacity: 0; pointer-events: none; transition: opacity .18s; }
+.media-pager.show .mp-seek.ready, .mp-seek.ready.reveal, .mp-seek.ready.drag { opacity: 1; pointer-events: auto; }
+/* 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 {
@@ -1689,6 +1846,7 @@ body::after {
color: #fff; font-size: 14px; text-align: center; text-shadow: 0 1px 3px rgba(0, 0, 0, .6);
}
.vid-nopic a { color: #fff; text-decoration: underline; pointer-events: auto; }
+.note-video-box.gone .note-video { max-height: 160px !important; aspect-ratio: 16 / 9; }
.media-tile-cost {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: rgba(255, 255, 255, .8); font-size: 12px; font-weight: 600; background: #16181d;
@@ -1788,6 +1946,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 +1970,120 @@ 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; }
+
+/* A call (calls.js): over every page, its picture filling it when there is
+ one; minimized, a bar along the top. */
+.call-overlay { position: fixed; inset: 0; z-index: 1000; background: #101318; color: #fff;
+ display: flex; flex-direction: column; align-items: center; justify-content: space-between;
+ padding: max(28px, env(safe-area-inset-top)) 16px max(28px, env(safe-area-inset-bottom)); }
+/* the whole of their picture, whatever its shape and the window's: a phone's
+ portrait camera filled a laptop's wide window by cropping to a forehead */
+.call-overlay .call-remote { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; display: none; }
+/* a call over the whole window: the page under it stays put */
+html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: auto; }
+.call-overlay.has-video .call-remote { display: block; }
+.call-overlay.has-video .call-who { position: relative; text-shadow: 0 1px 3px rgba(0, 0, 0, .7); }
+.call-overlay.has-video .call-who .call-avatar { display: none; }
+.call-who { display: flex; flex-direction: column; align-items: center; gap: 10px; margin-top: 8vh; z-index: 1; }
+.call-who .call-avatar { width: 112px; height: 112px; font-size: 32px; }
+.call-name { font-size: 22px; font-weight: 650; }
+.call-status { font-size: 15px; opacity: .8; font-variant-numeric: tabular-nums; }
+.call-local { position: absolute; right: 16px; bottom: 120px; width: 28vw; max-width: 180px; aspect-ratio: 3 / 4; object-fit: cover;
+ border-radius: 12px; background: #000; box-shadow: 0 2px 12px rgba(0, 0, 0, .5); z-index: 2; }
+.call-local.mirror { transform: scaleX(-1); }
+.call-local.sharing { aspect-ratio: 16 / 10; object-fit: contain; background: #000; }
+.call-bar { display: flex; gap: 18px; z-index: 2; flex-wrap: wrap; justify-content: center; }
+.call-btn { width: 60px; height: 60px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
+ background: rgba(255, 255, 255, .16); color: #fff; cursor: pointer; padding: 0; }
+.call-btn:hover { background: rgba(255, 255, 255, .26); }
+.call-btn.on { background: #fff; color: #101318; }
+.call-btn.call-hang, .call-btn.call-decline { background: #e0245e; }
+.call-btn.call-accept { background: #1fa45a; }
+.call-overlay.call-min { inset: 0 0 auto 0; padding: 0; background: none; height: auto; justify-content: flex-start; pointer-events: none; }
+.call-min-bar { pointer-events: auto; margin: max(8px, env(safe-area-inset-top)) auto 0; display: flex; align-items: center; gap: 10px;
+ padding: 8px 16px; border-radius: 999px; border: 0; background: #1fa45a; color: #fff; font-weight: 600; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, .3); }
+.call-min-status { font-variant-numeric: tabular-nums; opacity: .9; }
+.call-min .call-audio { display: none; }
+/* a call in its conversation: a row of its own, centred, a tap calls back */
+.chat-row.call-log { justify-content: center; margin: 6px 0; flex-direction: column; align-items: center; gap: 3px; }
+.call-log-why { text-align: center; max-width: 320px; }
+.call-log-pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px;
+ border: 1px solid var(--line); background: var(--surface2); color: inherit; font-size: 13px; cursor: pointer; }
+.call-log-pill .chat-time { margin: 0; font-size: 11px; opacity: .7; }
+.call-log-pill svg { width: 16px; height: 16px; }
+.chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
+
+/* A post's audio/video timeline: the waveform (audio), and what was said,
+ reacted or zapped at a moment, sitting over the bar at that moment. */
+.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); }
+.note-video-box .media-timeline:not(.has-marks) { display: none; }
+.media-time { font-variant-numeric: tabular-nums; }
+.media-seek { position: relative; height: 42px; --played: 0%; }
+.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px;
+ background: linear-gradient(to right, var(--accent) var(--played), var(--line) var(--played)); }
+.media-wave:empty { height: 4px; top: 19px; }
+.media-wave i { flex: 1; background: var(--accent); border-radius: 2px; min-width: 1px; }
+.has-wave .media-wave { background: none; }
+.has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; }
+.media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; }
+.media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+/* the marks hang just under the line, at their moment (a plain bar: inside
+ its touch strip); under a waveform, below it */
+.media-marks { position: absolute; left: 0; right: 0; top: 24px; height: 18px; pointer-events: none; }
+.has-wave .media-marks { top: 100%; height: 22px; }
+.media-seek.has-wave:has(.media-mark) { margin-bottom: 22px; }
+.media-mark { bottom: auto; top: 0; }
+.media-mark, .mp-mark {
+ position: absolute; bottom: 0; transform: translateX(-50%); padding: 0 2px; margin: 0; border: 0; background: none;
+ font-size: 16px; line-height: 1; cursor: pointer; pointer-events: auto;
+}
+.media-mark .cemoji, .mp-mark .cemoji { width: 20px; height: 20px; vertical-align: bottom; }
+.media-mark-face { display: block; width: 20px !important; height: 20px !important; min-width: 0; font-size: 8px; border-radius: 50%; }
+.mp-mark .media-mark-face { width: 26px !important; height: 26px !important; box-shadow: 0 0 0 1.5px #fff; }
+.mp-pop .media-mark-face { width: 36px !important; height: 36px !important; box-shadow: 0 0 0 2px #fff; }
+/* the pager's: above its seek bar, there when the bar is */
+.mp-marks { position: absolute; left: 0; right: 0; bottom: 10px; height: 26px; pointer-events: none; }
+.mp-mark { font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
+.mp-mark .cemoji { width: 24px; height: 24px; }
+.mp-seek-time { max-width: calc(100% - 16px); overflow: hidden; text-overflow: ellipsis; }
+.mp-seek.say .mp-seek-time { opacity: 1; }
+.mp-marks:not(:empty) ~ .mp-seek-time { bottom: 42px; }
+/* ...and a moment the clip plays through floats up off the bar */
+.mp-pop {
+ position: absolute; bottom: calc(var(--mp-seek-lift) + 12px); z-index: 4; transform: translateX(-50%);
+ font-size: 30px; line-height: 1; pointer-events: none; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .6));
+ animation: mp-pop 1.6s ease-out forwards;
+}
+.mp-pop .cemoji { width: 34px; height: 34px; }
+@keyframes mp-pop {
+ 0% { opacity: 0; transform: translate(-50%, 8px) scale(.6); }
+ 15% { opacity: 1; transform: translate(-50%, 0) scale(1.1); }
+ 100% { opacity: 0; transform: translate(-50%, -90px) scale(1); }
+}
+@media (prefers-reduced-motion: reduce) { .mp-pop { animation: none; opacity: 0; } }
+
+/* a timed comment's time: a chip that plays the clip from there */
+.media-time-link { display: inline-flex; align-items: center; margin-right: 6px; padding: 1px 8px; border-radius: 999px;
+ background: var(--accent-soft); color: var(--accent); font-size: .85em; font-weight: 650; font-variant-numeric: tabular-nums;
+ text-decoration: none; vertical-align: 1px; white-space: nowrap; }
+.media-time-link:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
+.media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; }
+.media-moment-detail:empty { display: none; }
+.live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
+.live-who { display: flex; flex-wrap: wrap; gap: 6px; }
+.live-who-p { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--bg2, rgba(127,127,127,.12)); border: 0; color: inherit; cursor: pointer; }
+
+/* a gift's note: the sender's words above the amount, and the field for them */
+.gift-note {
+ margin: 0; padding: 8px 12px; max-width: 100%;
+ border-left: 3px solid var(--accent); border-radius: 4px;
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
+ font-style: italic; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere;
+}
+.gift-note-input { width: 100%; min-height: 0; font-family: var(--sans); }
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index ddcdbb5..3a48cff 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -4,20 +4,121 @@
// nwc-respond.js), and only falls back to the wake-the-user notification
// when it can't.
-import { respondFromBg } from './nwc-respond.js';
-import { withRequestLock } from './nwc-lock.js';
+import { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js';
+import { withRequestLock, withWorkLock } from './nwc-lock.js';
+import { appendBgLog, takeLease } from './nwc-bg.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
-import { allInboxes, classifyDm, shouldNotifyDm } from './dm-inbox.js';
+import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
+// The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is
+// written, so a worker the phone stops mid-push leaves its last step behind.
+const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); };
+diary(`worker started${typeof navigator !== 'undefined' && navigator.locks ? '' : ' (no Web Locks)'}`);
+const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens
+// where a call's offer wrap is fetched from: the notifier, or (the app served
+// on this machine, as the tests do) this origin
+const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER;
+
+// WebKit (Safari, and every browser on iOS) allows no silent pushes: a push
+// event that ends without showing a notification is a strike, even with the
+// app open, and after three it drops the subscription. The wallet then stops
+// waking until it is opened again. Elsewhere a quiet push is fine, so only an
+// Apple endpoint gets a stand-in where we'd otherwise stay silent: closed at
+// once when the app is on screen (it shows everything live there), left as
+// one self-replacing silent line in the background.
+async function applePush() {
+ try {
+ const s = await self.registration.pushManager.getSubscription();
+ return !!s && /(^|\.)push\.apple\.com$/.test(new URL(s.endpoint).hostname);
+ } catch (_) { return false; }
+}
+async function standIn(visible) {
+ if (!(await applePush())) return false;
+ await self.registration.showNotification('Coinos', {
+ body: 'Your wallet checked in.', icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: 'coinos-bg', renotify: false, silent: true, data: { url: './' },
+ });
+ if (visible) {
+ const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []);
+ for (const n of open) n.close();
+ }
+ return true;
+}
self.addEventListener('push', (e) => {
let data = {};
try { data = e.data ? e.data.json() : {}; } catch (_) {}
+ // every path below ends in a notification or, failing that, the stand-in
+ let showed = false;
+ // A notification the platform refuses must not count as shown: the old
+ // code flagged it before the call, so a refusal left the push with no line
+ // at all — a silent push, on iOS a strike towards losing the subscription.
+ // (PullCap's night of 2026-10-22: paid four times, no "Payment sent" seen.)
+ // Try the full options; refused, try again with the plainest ones.
+ const show = async (title, opts) => {
+ try { await self.registration.showNotification(title, opts); showed = true; diary(`notification shown: ${title}`); return; }
+ catch (e) { diary(`notification refused (${title}): ${e && e.message}`); }
+ try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; diary(`plain notification shown: ${title}`); }
+ catch (e) { diary(`plain notification refused too (${title}): ${e && e.message}`); }
+ };
+ diary(`push ${data.type || '?'}${data.reason ? '/' + data.reason : ''}${data.event && data.event.id ? ' ' + data.event.id.slice(0, 8) : ''}`);
+ // the stand-in, as soon as a path knows it has nothing to say (a long tail
+ // of work after it mustn't keep the push waiting for its notification)
+ const quiet = async () => {
+ if (showed) return;
+ showed = true;
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }).catch(() => []);
+ await standIn(clients.some((c) => c.visibilityState === 'visible')).catch(() => {});
+ };
+ const settle = (work) => e.waitUntil((async () => {
+ try { await work(); } finally { await quiet(); }
+ })());
// User-facing notifications (payments, DMs, community chat). An open
// window already shows everything live — stay quiet then.
+ // A call ringing in: the push names the offer's wrap, kept at the notifier
+ // (too big to ride in a push). Opened here with our key; a fresh offer
+ // from someone we follow or talk to rings, as a notification that stays
+ // until it's answered or missed. The offer is kept for the app to pick up
+ // when the tap opens it (CALL_CACHE). An open window rings by itself.
+ if (data.type === 'notify' && data.reason === 'call') {
+ settle(async () => {
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ if (clients.some((c) => c.visibilityState === 'visible')) return;
+ let ring = null;
+ try {
+ const r = await fetch(WRAPS + '/wrap/' + encodeURIComponent(data.wrapId || ''));
+ if (r.ok) ring = await classifyCall(await r.json(), await allInboxes());
+ } catch (err) { console.warn('[sw-call] could not open:', err && err.message); }
+ if (!ring) return;
+ if (ring.quiet) {
+ // no ring for them; a note for the app, which lists it as a missed call
+ try {
+ const cache = await caches.open(CALL_CACHE);
+ const had = await cache.match('/__quiet-calls');
+ const list = had ? await had.json() : [];
+ list.push(ring.offer);
+ await cache.put('/__quiet-calls', new Response(JSON.stringify(list.slice(-20)), { headers: { 'content-type': 'application/json' } }));
+ } catch (_) {}
+ return;
+ }
+ try {
+ const cache = await caches.open(CALL_CACHE);
+ await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } }));
+ } catch (_) {}
+ for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} }
+ await show((ring.name || 'Someone') + ' is calling', {
+ body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.',
+ icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400],
+ data: { url: './?open=call', view: 'call' },
+ });
+ });
+ return;
+ }
if (data.type === 'notify') {
- e.waitUntil((async () => {
+ settle(async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.some((c) => c.visibilityState === 'visible')) return;
// Money landing is exactly when a forwarding rule wants to run, and this
@@ -60,58 +161,112 @@ self.addEventListener('push', (e) => {
const [title, body] = T[data.reason] || T.chat;
// a reply lands you in the Notifications list, where it is
const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' };
- await self.registration.showNotification(title, {
+ await show(title, {
body,
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'notify-' + (data.reason || 'chat'), renotify: ['payment', 'zap', 'mention'].includes(data.reason),
data: target,
});
- })());
+ });
return;
}
- if (data.type !== 'nwc') return;
- e.waitUntil((async () => {
+ if (data.type !== 'nwc') { settle(async () => {}); return; }
+ const startedAt = Date.now();
+ settle(async () => {
+ // WebKit's silent-push quota counts a push whose notification comes
+ // late: a payment can take most of the push budget, and a worker stopped
+ // before its last line never shows one (PullCap's night of 2026-10-22:
+ // four payments, no line for the fourth, then no more wakes). On an
+ // Apple endpoint the stand-in goes up FIRST; the outcome replaces it.
+ const apple = await applePush();
+ // What a push may take. iOS stops the worker about ten seconds in (PullCap's
+ // diary of 2026-10-24: every wake's last line 8–12 s after its push, in the
+ // middle of a payment), whatever the waitUntil — so there everything is
+ // planned inside nine; elsewhere the usual thirty.
+ const budgetEnd = startedAt + (apple ? 9_000 : 27_000);
+ const dropStandIn = async () => {
+ const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []);
+ for (const n of open) n.close();
+ };
+ if (apple && await standIn(false).catch((e) => { diary('stand-in refused: ' + (e && e.message)); return false; })) { showed = true; diary('stand-in shown'); }
// An open window handles requests itself with full wallet state — nudge
- // it and stay out of the way.
- const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ // it and stay out of the way. On iOS only one on screen counts: a
+ // backgrounded app is suspended, so a nudge to it went unanswered.
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const clients = all.filter((c) => !apple || c.visibilityState === 'visible');
if (clients.length) {
for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
+ if (apple) await dropStandIn(); // the window on screen shows it live
+ diary('window on screen — handed over');
return;
}
+ const notice = (n) => show(n.title, {
+ body: n.body, icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: n.tag || 'nwc-incoming', renotify: !apple, data: { url: './' },
+ });
+ // A wake is the only moment a closed wallet gets to look after its coins:
+ // finish sends an earlier wake left in flight, claim renewals whose round
+ // has run, cover coins without one. After the answer, inside what is
+ // left of the push budget.
+ const tend = async () => {
+ const late = await tendLnPays({ deadline: budgetEnd, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; });
+ for (const n of late) await notice(n);
+ if (late.length && apple) await dropStandIn();
+ await tendRenewals({ deadline: budgetEnd, log: (m) => diary('renew: ' + m) })
+ .catch((err) => diary('renewals 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;
- try {
- handled = await withRequestLock(data.event && data.event.id,
- () => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }),
- () => { console.log('[sw-nwc] another context holds this request'); return true; });
- } catch (err) {
- console.warn('[sw-nwc] auto-answer failed:', err && err.message);
- }
- // handled-with-a-heads-up: e.g. an invoice was minted while closed and
- // the user should open coinos to complete the receive
- if (handled && handled.notify) {
- await self.registration.showNotification(handled.notify.title, {
- body: handled.notify.body,
+ // one push at a time over the shared mirror (a client's retry lands
+ // seconds after its lookup, and each push builds its own manager)
+ await withWorkLock(async () => {
+ const waited = Date.now() - startedAt;
+ if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`);
+ if (Date.now() > budgetEnd - 2_000) { diary('no time left after waiting — leaving it to the next try'); return; }
+ let handled = false;
+ try {
+ handled = await withRequestLock(data.event && data.event.id,
+ () => respondFromBg(data, { notifier: NOTIFIER, log: diary, deadline: budgetEnd }),
+ () => { diary('another context holds this request'); return true; });
+ } catch (err) {
+ diary('auto-answer failed: ' + (err && err.message));
+ }
+ // sends from an earlier wake that settled on the way to this answer
+ const late = (handled && handled.notices) || [];
+ for (const n of late) await notice(n);
+ // handled-with-a-heads-up: e.g. an invoice was minted while closed and
+ // the user should open coinos to complete the receive
+ if (handled && handled.notify) {
+ await notice(handled.notify);
+ if (apple) await dropStandIn();
+ await tend();
+ return;
+ }
+ if (handled) {
+ if (late.length && apple) await dropStandIn();
+ diary('answered quietly');
+ await quiet();
+ await tend();
+ return;
+ }
+ if (Date.now() > budgetEnd) { diary('out of time — leaving it to the next try'); 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;
+ diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request');
+ await show(incoming ? 'Incoming payment' : 'Payment request', {
+ body: incoming
+ ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.'
+ : 'An app is asking your wallet to pay. Open Coinos to approve.',
icon: 'icon-192.png', badge: 'badge-96.png',
- tag: handled.notify.tag || 'nwc-incoming', renotify: true,
+ tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },
});
- return;
- }
- if (handled) 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;
- await self.registration.showNotification(incoming ? 'Incoming payment' : 'Payment request', {
- body: incoming
- ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.'
- : 'An app is asking your wallet to pay. Open Coinos to approve.',
- icon: 'icon-192.png', badge: 'badge-96.png',
- tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
- data: { url: './' },
- });
- })());
+ if (apple) await dropStandIn();
+ await tend();
+ }, { lease: () => takeLease('work', { wait: Math.max(0, budgetEnd - Date.now() - 3_000) }) });
+ diary(`push done in ${Date.now() - startedAt} ms`);
+ });
});
self.addEventListener('notificationclick', (e) => {
diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js
index 6d34db6..c21289c 100644
--- a/src/thread-prefetch.js
+++ b/src/thread-prefetch.js
@@ -1,5 +1,9 @@
// Dwell is measured on mounted rows, including posts taller than the viewport.
// Sampling also catches fast movement within a large post (IO alone cannot).
+// A post read for half a second is a post that may be opened: its thread is
+// fetched then (it was 1.2 s, which a quick reader beat to the tap), one at
+// a time, a dozen a minute.
+const DWELL_MS = 600, PER_MINUTE = 12;
export function createThreadPrefetch({ load, key, cached, doc = globalThis.document,
nav = globalThis.navigator, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) {
const seeds = new Map(), dwell = new Map();
@@ -23,12 +27,12 @@ export function createThreadPrefetch({ load, key, cached, doc = globalThis.docum
if (!d || Math.abs(r.top - d.top) > 25) d = { since: time, top: r.top };
d.top = r.top;
dwell.set(id, d);
- if (time - d.since >= 1200) ready.push(seed);
+ if (time - d.since >= DWELL_MS) ready.push(seed);
}
for (const id of seeds.keys()) if (!mounted.has(id)) seeds.delete(id);
for (const id of dwell.keys()) if (!visible.has(id)) dwell.delete(id);
starts = starts.filter((t) => time - t < 60000);
- if (!busy && starts.length < 6 && ready.length) {
+ if (!busy && starts.length < PER_MINUTE && ready.length) {
const seed = ready[0], token = generation;
busy = true; starts.push(time);
// The loader inserts synchronously, so clicks share its in-flight work.
diff --git a/src/video-compress.js b/src/video-compress.js
index 53d4eb0..8fe0856 100644
--- a/src/video-compress.js
+++ b/src/video-compress.js
@@ -52,3 +52,66 @@ export async function compressVideo(file, onProgress = () => {}) {
try { input.dispose?.(); } catch {}
}
}
+
+// A long video as an HLS stream (Adam, 2026-10-18; jb55: "you should use
+// hls (m3u8 ext) for long videos"): a 720p and a 360p rendition, the
+// player taking the one the connection carries, so an hour-long clip
+// neither stalls on mobile data nor costs the whole file to watch a
+// minute of it. Made here, like the shrinking above, in 4-second segments,
+// each rendition (and the sound) one file read in ranges — a handful of
+// uploads, not hundreds. The files keep the names the playlists give them;
+// uploadStream puts them on a host and the playlists on after them.
+// Anything short of a stream — a short clip, no WebCodecs, an error — is
+// null, and the video goes up as one file as before.
+export const STREAM_MIN_S = 90;
+export async function streamVideo(file, onProgress = () => {}) {
+ if (!file || !/^video\//.test(file.type || '')) return null;
+ if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return null;
+ let mb;
+ try { mb = await import('mediabunny'); } catch { return null; }
+ const { Input, Output, Conversion, BlobSource, BufferTarget, PathedTarget, HlsOutputFormat, CmafOutputFormat, ALL_FORMATS } = mb;
+ if (!HlsOutputFormat || !PathedTarget) return null;
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
+ try {
+ const video = await input.getPrimaryVideoTrack();
+ if (!video) return null;
+ const seconds = await input.computeDuration();
+ if (!(seconds >= STREAM_MIN_S)) return null;
+ const w = video.displayWidth, h = video.displayHeight, short = Math.min(w, h);
+ const edge = (n) => (w >= h ? { height: Math.min(n, short) } : { width: Math.min(n, short) });
+ const rungs = [{ ...edge(SHORT_EDGE), bitrate: VIDEO_BPS }, { ...edge(360), bitrate: 800_000 }];
+ const files = new Map(); // path -> { target, mime }
+ const target = new PathedTarget('master.m3u8', (req) => {
+ const t = new BufferTarget();
+ files.set(req.path, { target: t, mime: req.mimeType });
+ return t;
+ });
+ const output = new Output({
+ target,
+ format: new HlsOutputFormat({
+ segmentFormat: new CmafOutputFormat(), singleFilePerPlaylist: true, targetDuration: 4,
+ getPlaylistPath: ({ n }) => `p${n}.m3u8`,
+ getSegmentPath: ({ playlist }) => `s${playlist.n}.mp4`, // (a host serves .mp4 to anyone; .m4s it may not take)
+ }),
+ });
+ const conversion = await Conversion.init({
+ input, output,
+ video: rungs.map((r) => ({ ...r, codec: 'avc', keyFrameInterval: 4, forceTranscode: true })),
+ });
+ if (!conversion.isValid) return null;
+ conversion.onProgress = (p) => { try { onProgress(p); } catch {} };
+ await conversion.execute();
+ const out = new Map();
+ for (const [path, { target: t, mime }] of files) {
+ if (!t.buffer) return null;
+ out.set(path, new File([t.buffer], path, { type: /m3u8$/.test(path) ? 'application/vnd.apple.mpegurl' : mime || 'video/mp4' }));
+ }
+ if (!out.has('master.m3u8')) return null;
+ const dim = w >= h ? `${Math.round(w * Math.min(1, SHORT_EDGE / short))}x${Math.min(SHORT_EDGE, short)}` : `${Math.min(SHORT_EDGE, short)}x${Math.round(h * Math.min(1, SHORT_EDGE / short))}`;
+ return { files: out, master: 'master.m3u8', seconds, dim };
+ } catch {
+ return null;
+ } finally {
+ try { input.dispose?.(); } catch {}
+ }
+}
diff --git a/static/docs/index.html b/static/docs/index.html
new file mode 100644
index 0000000..eec46e8
--- /dev/null
+++ b/static/docs/index.html
@@ -0,0 +1,182 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="color-scheme" content="light dark">
+<title>Coinos for developers</title>
+<meta name="description" content="How to build on coinos: Lightning addresses, Nostr Wallet Connect, the coinos REST API, and testing on mutinynet.">
+<link rel="icon" href="/icon.svg" type="image/svg+xml">
+<style>
+@font-face { font-family: 'Public Sans'; src: url('/PublicSans.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }
+:root {
+ --bg: #fafaf9; --fg: #1c1917; --muted: #57534e; --faint: #a8a29e;
+ --card: #ffffff; --line: #e7e5e4; --code: #f5f5f4; --accent: #f7931a; --link: #c2410c;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --faint: #57534e;
+ --card: #1c1917; --line: #292524; --code: #1c1917; --link: #fb923c;
+ }
+}
+:root[data-theme="dark"] {
+ --bg: #0c0a09; --fg: #f5f5f4; --muted: #a8a29e; --faint: #57534e;
+ --card: #1c1917; --line: #292524; --code: #1c1917; --link: #fb923c;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 'Public Sans', system-ui, sans-serif; }
+main { max-width: 760px; margin: 0 auto; padding: 32px 16px 80px; }
+header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
+header img { width: 36px; height: 36px; }
+header a { color: var(--fg); text-decoration: none; font-weight: 700; font-size: 20px; }
+h1 { font-size: 32px; line-height: 1.2; margin: 24px 0 8px; }
+h2 { font-size: 22px; margin: 48px 0 8px; padding-top: 8px; border-top: 1px solid var(--line); }
+h3 { font-size: 17px; margin: 28px 0 6px; }
+p, li { color: var(--fg); }
+.lede { color: var(--muted); font-size: 18px; }
+a { color: var(--link); }
+code { font: 13.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--code); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
+pre { background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow-x: auto; }
+pre code { border: 0; padding: 0; background: none; overflow-wrap: normal; }
+nav.toc { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 18px; margin: 24px 0; }
+nav.toc ol { margin: 0; padding-left: 20px; }
+table { width: 100%; border-collapse: collapse; font-size: 14.5px; margin: 8px 0; }
+th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
+th { color: var(--muted); font-weight: 600; }
+.note { border-left: 3px solid var(--accent); background: var(--card); padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 16px 0; }
+.wrap { overflow-x: auto; }
+footer { margin-top: 64px; color: var(--faint); font-size: 14px; }
+</style>
+</head>
+<body>
+<main>
+<header><img src="/icon.svg" alt=""><a href="/">coinos</a></header>
+
+<h1>Building on coinos</h1>
+<p class="lede">There are two coinos wallets, and they connect to other software differently. The new one (v3, this site) keeps your keys on your device. The original one at coinos.io is a hosted account with a REST API.</p>
+
+<nav class="toc"><ol>
+ <li><a href="#which">Which one should I build against?</a></li>
+ <li><a href="#addresses">Receiving: Lightning addresses, LNURL, BIP-353</a></li>
+ <li><a href="#nwc">Nostr Wallet Connect (v3)</a></li>
+ <li><a href="#rest">The coinos REST API (hosted accounts)</a></li>
+ <li><a href="#migration">Moving a username to v3</a></li>
+ <li><a href="#testing">Testing without real money</a></li>
+ <li><a href="#android">Android app</a></li>
+ <li><a href="#source">Source code</a></li>
+</ol></nav>
+
+<h2 id="which">Which one should I build against?</h2>
+<div class="wrap"><table>
+ <tr><th></th><th>coinos v3 (v3.coinos.io)</th><th>coinos.io hosted accounts</th></tr>
+ <tr><td>Custody</td><td>Self-custodial. The wallet runs in the browser or the app; keys never leave the device.</td><td>Custodial. coinos holds the balance.</td></tr>
+ <tr><td>Programmatic access</td><td>Nostr Wallet Connect (NIP-47), with spending limits you set per connection</td><td>REST API with a bearer token, plus a websocket</td></tr>
+ <tr><td>Receive</td><td>Lightning address, LNURL-pay, BOLT12, Ark, on-chain</td><td>Lightning, on-chain, Liquid, internal</td></tr>
+ <tr><td>Send on-chain from code</td><td>No</td><td>Yes: <code>/bitcoin/send</code></td></tr>
+ <tr><td>Good for</td><td>Apps that pay or charge a user's own wallet: zaps, tips, small checkouts</td><td>Services that run unattended: shops, bots, swaps, POS hardware</td></tr>
+</table></div>
+<p>v3 has no server-side API or token. There's no account on our side for one to reach. If your service has to run around the clock without anyone's wallet being open, use a hosted coinos.io account and the REST API.</p>
+
+<h2 id="addresses">Receiving: Lightning addresses, LNURL, BIP-353</h2>
+<p>Every coinos name <code>[email protected]</code> is a Lightning address, and it works the same whether the name belongs to a v3 wallet or a hosted account.</p>
+<h3>LNURL-pay (LUD-06/16)</h3>
+<pre><code>GET https://coinos.io/.well-known/lnurlp/&lt;name&gt;</code></pre>
+<p>This returns a standard <code>payRequest</code>. Call its <code>callback</code> with <code>?amount=&lt;millisats&gt;</code> to get a BOLT11 invoice. Comments up to 200 characters are accepted (LUD-12). <code>allowsNostr</code> is true, so NIP-57 zaps work and receipts are published.</p>
+<h3>Nostr identifiers (NIP-05)</h3>
+<pre><code>GET https://coinos.io/.well-known/nostr.json?name=&lt;name&gt;</code></pre>
+<h3>BIP-353 (₿[email protected])</h3>
+<p>v3 names publish a DNSSEC-signed payment instruction:</p>
+<pre><code>dig TXT &lt;name&gt;.user._bitcoin-payment.coinos.io</code></pre>
+<p>The record is a <code>bitcoin:</code> URI carrying an Ark address and a BOLT12 offer (<code>lno=</code>). Wallets that speak BIP-353 can pay it with no web request at all.</p>
+
+<h2 id="nwc">Nostr Wallet Connect (v3)</h2>
+<p>NWC lets your app ask a user's v3 wallet to pay or create invoices over nostr. Your app never sees the wallet's keys. The user decides how much it may spend, and can cut it off at any time.</p>
+<h3>Getting a connection string</h3>
+<ol>
+ <li>In coinos, open <strong>Settings → Nostr → Nostr Wallet Connect</strong>.</li>
+ <li><strong>Add a connection</strong>: give it a name, a maximum per payment, and a budget (per day, week or month).</li>
+ <li>Copy the <code>nostr+walletconnect://…</code> string into your app.</li>
+</ol>
+<h3>Supported methods</h3>
+<div class="wrap"><table>
+ <tr><th>Method</th><th>Notes</th></tr>
+ <tr><td><code>get_info</code></td><td>Lists the methods below.</td></tr>
+ <tr><td><code>get_balance</code></td><td>Spendable balance, in millisats.</td></tr>
+ <tr><td><code>get_budget</code></td><td>What's left of this connection's budget, and when it renews.</td></tr>
+ <tr><td><code>pay_invoice</code></td><td>BOLT11. Refused past the per-payment cap or the budget.</td></tr>
+ <tr><td><code>make_invoice</code></td><td>A BOLT11 invoice paid into the wallet.</td></tr>
+ <tr><td><code>lookup_invoice</code></td><td>By <code>payment_hash</code> or <code>invoice</code>; tells you whether it's settled.</td></tr>
+ <tr><td><code>list_transactions</code></td><td>Recent payments in and out.</td></tr>
+</table></div>
+<p>NIP-47 has no on-chain method, and coinos doesn't add one. A revoked connection answers <code>UNAUTHORIZED</code>.</p>
+<div class="note"><strong>Who answers.</strong> Requests are answered by the user's wallet itself, not by a coinos server. It answers while coinos is open, and in the background too if the user turned on <em>Answer while closed</em>, which uses push notifications to wake it. Expect a few seconds of latency when it has to wake. Don't rely on it like an always-on server.</div>
+<p>Requests and responses go through <code>wss://relay.coinos.io</code>, plus any relays in the connection string.</p>
+
+<h2 id="rest">The coinos REST API (hosted accounts)</h2>
+<p>The REST API at <code>https://coinos.io/api</code> keeps working for hosted accounts. The full reference is at <a href="https://coinos.io/docs">coinos.io/docs</a>. A short tour:</p>
+<pre><code>BASE=https://coinos.io/api
+
+# an account and a token (or copy a token from the coinos.io settings page)
+curl -X POST $BASE/register -H 'content-type: application/json' \
+ -d '{"user":{"username":"myshop","password":"…"}}'
+curl -X POST $BASE/login -H 'content-type: application/json' \
+ -d '{"username":"myshop","password":"…"}' # → {"token": "…"}
+
+AUTH="authorization: Bearer $TOKEN"
+curl $BASE/me -H "$AUTH" # balance and profile
+
+# receive: a Lightning invoice (type "bitcoin" or "liquid" for an address)
+curl -X POST $BASE/invoice -H "$AUTH" -H 'content-type: application/json' \
+ -d '{"invoice":{"amount":1000,"type":"lightning"}}' # → {"hash", "text": "lnbc…", …}
+curl $BASE/invoice/&lt;hash&gt; # received / pending
+
+# pay a Lightning invoice
+curl -X POST $BASE/payments -H "$AUTH" -H 'content-type: application/json' \
+ -d '{"payreq":"lnbc…"}'
+
+# pay a Lightning address
+curl -X POST $BASE/send/[email protected]/1000 -H "$AUTH"
+
+# on-chain: estimate, then send
+curl -X POST $BASE/bitcoin/fee -H "$AUTH" -H 'content-type: application/json' -d '{"address":"bc1…","amount":50000}'
+curl -X POST $BASE/bitcoin/send -H "$AUTH" -H 'content-type: application/json' -d '{"address":"bc1…","amount":50000}'
+
+curl "$BASE/payments" -H "$AUTH" # history</code></pre>
+<p>Don't poll for payments: connect to <code>wss://coinos.io/ws</code>, send <code>{"type":"login","data":"&lt;token&gt;"}</code>, and receive payment events as they happen. Exact request fields can change; <a href="https://coinos.io/docs">coinos.io/docs</a> is authoritative.</p>
+<div class="note">When v3 takes over the coinos.io homepage, the hosted wallet and its API move to <code>v2.coinos.io</code>. We'll announce the date ahead of time. Until then nothing changes, and <code>https://coinos.io/api</code> is the right base URL. <code>v3.coinos.io</code> has no equivalent API.</div>
+
+<h2 id="migration">Moving a username to v3</h2>
+<p>A hosted account's username can move to a v3 wallet. After that, <strong>new payments to the hosted account</strong> (Lightning, BOLT12, on-chain, Liquid) are forwarded to the v3 wallet automatically. Payments between coinos accounts aren't forwarded. Your API token keeps working, but the money won't stay in the hosted balance. So if your code receives into a hosted account and then spends from it (a swap or a payout), <strong>don't migrate that account's username</strong>. Use a separate name for your v3 wallet.</p>
+
+<h2 id="testing">Testing without real money</h2>
+<p>Both wallets have a copy on <a href="https://mutinynet.com">mutinynet</a>, a test network with 30-second blocks. Its coins are free and worthless.</p>
+<div class="wrap"><table>
+ <tr><th>What</th><th>Where</th></tr>
+ <tr><td>REST API on mutinynet</td><td><code>https://staging.coinos.io/api</code>: the same endpoints as coinos.io/api, with separate accounts. Websocket: <code>wss://staging.coinos.io/api/ws</code></td></tr>
+ <tr><td>v3 wallet on mutinynet</td><td><a href="https://staging.coinos.io">staging.coinos.io</a>: the same app, defaulting to mutinynet, with its own Ark server and Lightning node</td></tr>
+ <tr><td>Test coins</td><td><a href="https://faucet.mutinynet.com">faucet.mutinynet.com</a>: on-chain coins, or pay a mutinynet Lightning invoice</td></tr>
+ <tr><td>Block explorer</td><td><a href="https://mutinynet.com">mutinynet.com</a></td></tr>
+</table></div>
+<p>Switch your base URL to <code>https://staging.coinos.io/api</code> and register a fresh account there. Mutinynet addresses start with <code>tb1</code>, and its invoices with <code>lntbs</code>. Liquid is switched off on staging. The test server is reset now and then, so don't keep anything there you'd miss.</p>
+
+<h2 id="android">Android app</h2>
+<p>v3 is a web app and runs in any browser; the Android app wraps it with the system share sheet, NFC and <code>lightning:</code>/<code>bitcoin:</code>/<code>nostr:</code> links. Same code, same keys on your device.</p>
+<ul>
+ <li>Direct download: <a href="https://v3.coinos.io/coinos.apk">v3.coinos.io/coinos.apk</a> (the current build, signed by coinos)</li>
+ <li>The same file from the source mirror: <a href="https://git.coinos.io/coinosv3.git/plain/static/coinos.apk">git.coinos.io/coinosv3.git/plain/static/coinos.apk</a> (1.14, SHA-256 <code>58a5c478…fdbfc1</code>; the full hash is in the commit that added it)</li>
+ <li><a href="https://zapstore.dev">Zapstore</a>: search for coinos; updates arrive through the store</li>
+ <li>F-Droid: a reproducible build is in review at <a href="https://gitlab.com/fdroid/fdroiddata/-/merge_requests/49241">fdroiddata</a></li>
+</ul>
+
+<h2 id="source">Source code</h2>
+<ul>
+ <li>coinos v3 (this app): <a href="https://github.com/coinos/coinosv3">github.com/coinos/coinosv3</a></li>
+ <li>The hosted wallet's server: <a href="https://github.com/coinos/coinos-server">github.com/coinos/coinos-server</a> (its routes are in <code>index.ts</code>)</li>
+ <li>Mirrors: <a href="https://git.coinos.io">git.coinos.io</a></li>
+</ul>
+<p>Questions, or something here that's wrong? Message <a href="/adam">[email protected]</a> on nostr.</p>
+
+<footer>coinos</footer>
+</main>
+</body>
+</html>
diff --git a/static/og-image.png b/static/og-image.png
new file mode 100644
index 0000000..c048083
--- /dev/null
+++ b/static/og-image.png
Binary files differ
diff --git a/tools/amber-dm-upgrade-test.js b/tools/amber-dm-upgrade-test.js
index 9a5070a..250e8ef 100644
--- a/tools/amber-dm-upgrade-test.js
+++ b/tools/amber-dm-upgrade-test.js
@@ -120,14 +120,14 @@ try {
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click());
console.log('\n[signed in with Amber by intent]');
- check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect Amber')));
+ check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect signer')));
await sleep(4000);
check('no decrypt intent went to Amber for an arriving wrap', !(await page.evaluate(() => window.__amberUrls.some((u) => /nip44_decrypt/.test(u)))));
check('neither message shows', !(await text()).includes('a message from before the upgrade') && !(await text()).includes('wrote off'));
if (!ALL) check('the arriving wrap is not logged as opened', await page.evaluate(([k, id]) => !(JSON.parse(localStorage.getItem(k + ':messages') || '{}').wraps || []).includes(id.slice(0, 16)), [base, NEW.id]));
console.log('\n[Connect Amber: the same account over NIP-46]');
- await click('connect amber'); await sleep(300);
+ await click('connect signer'); await sleep(300);
check('a screen explains it and offers the signer-app link', await waitText('read your messages', 5000));
let uri = null;
for (let i = 0; i < 40 && !uri; i++) { uri = await page.evaluate(() => document.querySelector('a[href^="nostrconnect://"]')?.getAttribute('href')); if (!uri) await sleep(250); }
diff --git a/tools/amber-login-test.js b/tools/amber-login-test.js
index 9da49a8..51f0991 100644
--- a/tools/amber-login-test.js
+++ b/tools/amber-login-test.js
@@ -41,8 +41,8 @@ try {
console.log('\n[the Amber door]');
check('the front door offers Nostr sign-in', await click('sign in with nostr') || await click('nostr'));
await sleep(500);
- check('on Android, the login offers Amber', await waitText('sign in with amber', 5000));
- await click('sign in with amber'); await sleep(400);
+ check('on Android, the login offers Amber', await waitText('sign in with a signer app', 5000));
+ await click('sign in with a signer app'); await sleep(400);
check('tapping it opens a get_public_key intent', (await lastType()) === 'get_public_key', JSON.stringify(await urls()));
check('...and shows the waiting card with a paste box', await waitText('waiting for your signer', 2000) && !!(await page.$('.amber-paste')));
await deliver(npubEncode(PK));
diff --git a/tools/amount-field-unit-test.js b/tools/amount-field-unit-test.js
index b984064..596969d 100644
--- a/tools/amount-field-unit-test.js
+++ b/tools/amount-field-unit-test.js
@@ -2,6 +2,8 @@
// showing: in fiat that is money to the cent, floored so a filled-in maximum
// never parses back to more sats than were offered — not a BTC figure under a
// CAD label (2026-10-08: Max on an ark recipient filled 0.00004978 'CAD').
+// Switching the unit with an amount already typed into the send form converts
+// it (2026-10-23): the same money in the new unit, round-tripping exactly.
// Run: bun tools/amount-field-unit-test.js
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
@@ -9,7 +11,7 @@ import puppeteer from './puppeteer.js';
const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
plugins: [{ name: 'amount-field-test', setup(build) {
build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
-window.amtApp = { ctx, toggleUnit, get unit() { return unit; } };
+window.amtApp = { ctx, ui, toggleUnit, get unit() { return unit; } };
` }));
build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
} }] });
@@ -37,6 +39,13 @@ try {
while (amtApp.unit !== 'sats') amtApp.toggleUnit();
out.sats = ctx.amountField(62212);
amtApp.toggleUnit(); out.btc = ctx.amountField(62212);
+ // typed into the send form, then the unit toggled around the ring
+ while (amtApp.unit !== 'sats') amtApp.toggleUnit();
+ const rs = amtApp.ui.send.recipients;
+ rs[0].amount = '62212'; rs.push({ address: '', amount: '93.318' }, { address: '', amount: '' }, { address: '', amount: 'abc' });
+ out.ring = [];
+ for (let i = 0; i < 3; i++) { amtApp.toggleUnit(); out.ring.push([amtApp.unit, ...rs.map((r) => r.amount)]); }
+ rs.splice(1);
return out;
});
console.log(r);
@@ -46,6 +55,11 @@ try {
assert.equal(r.typed, 62207, 'the same money typed as something else parses as typed');
assert.equal(r.sats, '62212');
assert.equal(r.btc, '0.00062212');
+ assert.deepEqual(r.ring, [
+ ['btc', '0.00062212', '0.00000093', '', 'abc'],
+ ['fiat', '93.31', '0.13', '', 'abc'],
+ ['sats', '62212', '93', '', 'abc'],
+ ], 'a typed amount follows the unit around the ring and comes back exact; blanks and junk are left alone');
assert.deepEqual(errors, []);
console.log('✅ amount fills follow the display unit');
} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark-htlc-orphan.test.js b/tools/ark-htlc-orphan.test.js
new file mode 100644
index 0000000..cc127be
--- /dev/null
+++ b/tools/ark-htlc-orphan.test.js
@@ -0,0 +1,33 @@
+// bun test tools/ark-htlc-orphan.test.js — an HTLC coin whose payment's
+// action has been pruned is an orphan the server settles: spent means spent
+// (it stopped counting as pending money), still-open stays pending, and a
+// coin a live payment holds is never asked about.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+const asked = [];
+mock.module('../src/ark/proto.js', () => ({ ...proto, getVtxoStatus: async (url, raw) => { asked.push(raw[0]); return raw[0] === 1 ? proto.VTXO_STATE_SPENT : proto.VTXO_STATE_SPENDABLE; } }));
+const { ArkManager } = await import('../src/ark/manager.js');
+test('orphan HTLC coins are settled by the server\'s answer', async () => {
+ const m = Object.create(ArkManager.prototype);
+ m.state = {
+ vtxos: [
+ { id: 'gone', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 1 }, // payment done long ago, action pruned: server says spent
+ { id: 'open', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 2 }, // orphan the server still holds open
+ { id: 'held', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 3 }, // a live payment's: not asked about
+ { id: 'coin', state: 'pending', amountSat: 500, bytes: 'x', policy: 'pubkey', tag: 2 }, // a plain orphan the server counts: spendable again
+ ],
+ actions: [{ type: 'ln-pay', step: 'initiated', parts: [{ inputId: 'in1' }], htlcVtxoIds: ['held'] }],
+ scheduled: [],
+ };
+ m.arkUrl = 'x';
+ m._decoded = (v) => ({ point: { raw: Uint8Array.of(v.tag) }, policy: { type: v.policy } });
+ m._keyForVtxo = () => ({ privkey: new Uint8Array(32) });
+ m._save = () => {}; m._movement = () => {}; m._remoteBusy = () => new Set();
+ await m.reconcile();
+ const st = Object.fromEntries(m.state.vtxos.map((v) => [v.id, v.state]));
+ expect(st.gone).toBe('spent');
+ expect(st.open).toBe('pending');
+ expect(st.held).toBe('pending');
+ expect(st.coin).toBe('spendable');
+ expect(asked.sort()).toEqual([1, 2, 2]);
+});
diff --git a/tools/ark-offline-init.test.js b/tools/ark-offline-init.test.js
new file mode 100644
index 0000000..922682d
--- /dev/null
+++ b/tools/ark-offline-init.test.js
@@ -0,0 +1,22 @@
+// bun test tools/ark-offline-init.test.js — the Ark server gone: with the
+// server's last published info in hand the manager still opens (coins read,
+// exits can start); without it, init fails as before.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+mock.module('../src/ark/proto.js', () => ({ ...proto,
+ getArkInfo: async () => { throw new Error('fetch failed'); },
+ handshake: async () => { throw new Error('fetch failed'); },
+}));
+const { ArkManager } = await import('../src/ark/manager.js');
+const serverPubkey = '02' + 'ab'.repeat(32);
+const hint = { serverPubkey, vtxoExpiryDelta: 8640, vtxoExitDelta: 144, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] }, offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] }, lnSendFees: {}, lnReceiveFees: {} };
+const mk = (infoHint) => new ArkManager({ account: null, storage: { load: () => ({ v: 1, serverPubkey, vtxos: [], actions: [], movements: [], scheduled: [] }), save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', infoHint });
+test('with cached info the manager opens offline', async () => {
+ const m = await mk(hint).init();
+ expect(m.offline).toBe(true);
+ expect(m.info.refreshFees.baseFeeSat).toBe(300);
+ expect(Buffer.from(m.serverPub).toString('hex')).toBe(serverPubkey);
+});
+test('without cached info the old failure stands', async () => {
+ await expect(mk(null).init()).rejects.toThrow('fetch failed');
+});
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/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js
index d8fa0d8..03e65d5 100644
--- a/tools/ark/cooperative-exit-ui-test.js
+++ b/tools/ark/cooperative-exit-ui-test.js
@@ -66,10 +66,9 @@ try {
});
await page.waitForSelector('.coin-table');
};
- const coopButton = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').click());
+ const coopButton = () => page.evaluate(() => document.querySelector('.ark-coop-go').click());
const send = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click());
+ .find((c) => c.querySelector('h3')?.textContent === 'Withdraw').querySelector('.btn-primary').click());
const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value);
// The balance card's gear is the way in: Spending's settings lead with
// its coins and carry what a unilateral exit costs; the home screen no
@@ -114,13 +113,16 @@ try {
await page.evaluate(() => { window.forceArkReady = false; coopApp.wallet.saveFeatureState('arkDepth', null); coopApp.ui.screen = 'wallet'; coopApp.render(); });
await manage();
- assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']);
+ // the three actions sit right under the table, named for the ticked total; no explainer cards
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew now', 'Withdraw 20,000 sats', 'Exit 20,000 sats']);
+ assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), []);
await coopButton();
await page.waitForSelector('input.mono-input');
const savings = await page.evaluate(() => coopApp.wallet.freshReceive().address);
assert.equal(await page.$eval('input.mono-input', (e) => e.value), savings);
assert.equal(await page.$eval('input[type="number"]', (e) => e.value), '20000');
assert.equal(await page.$eval('.card .btn-primary', (e) => e.textContent), 'Send');
+ assert.match(await page.evaluate(() => document.body.innerText), /one transaction into Savings, ready after a single confirmation/, 'the withdraw explainer is on the form');
assert.deepEqual(await page.evaluate(() => coopCalls), [], 'opening the form never starts an exit');
await page.evaluate(() => [...document.querySelectorAll('.card button')].find((b) => b.textContent.includes('Back')).click());
await page.waitForSelector('.coin-table');
@@ -162,12 +164,25 @@ try {
assert.deepEqual(await page.evaluate(() => coopCalls[1].script), await page.evaluate(() => [...coopApp.wallet.derive(0, 1).script]));
console.log('✓ Editing the destination and amount offboards only the requested amount to the chosen on-chain address');
+ // ticking a subset on Manage: exactly those coins go, no split, the amount is theirs and not a field
+ await manage();
+ await page.evaluate(() => { coopApp.ui.arkCoinsSel = new Set(['coin-b']); coopApp.render(); });
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew 1 selected for free', 'Withdraw 8,000 sats', 'Exit 8,000 sats'], 'the buttons name the ticked total');
+ await coopButton();
+ await page.waitForSelector('.ark-coop-chosen');
+ assert.match(await page.$eval('.ark-coop-chosen', (e) => e.textContent), /1 selected coin · 8,000 sats/);
+ assert.equal(await page.$('input[type="number"]'), null, 'no amount field for chosen coins');
+ await send();
+ await page.waitForFunction(() => coopCalls.length === 3 && !coopApp.ui.arkBusy);
+ assert.deepEqual(await page.evaluate(() => ({ type: coopCalls[2].type, ids: coopCalls[2].ids, address: coopCalls[2].address })), { type: 'offboard', ids: ['coin-b'], address: savings });
+ console.log('✓ Ticking a subset on Manage offboards exactly those coins, without splitting');
+
await manage(); await coopButton();
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Wallet"]').click());
assert.equal(await page.evaluate(() => coopApp.ui.arkOffboardSend), null);
await manage();
await page.evaluate(() => { coopManager.state.vtxos.forEach((v) => v.state = 'pending'); coopManager.state.actions = [{ type: 'refresh', step: 'submitted', inputIds: ['coin-a', 'coin-b'], inAmountSat: 20000 }]; coopApp.render(); });
- assert(await page.evaluate(() => [...document.querySelectorAll('.card')].find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').disabled));
+ assert(await page.evaluate(() => document.querySelector('.ark-coop-go').disabled));
await page.evaluate(() => {
const { ui, render } = coopApp;
ui.arkCoinsPage = null; ui.tab = 'send';
diff --git a/tools/ark/custody-foreign-test.js b/tools/ark/custody-foreign-test.js
new file mode 100644
index 0000000..148622d
--- /dev/null
+++ b/tools/ark/custody-foreign-test.js
@@ -0,0 +1,102 @@
+// On a server that does not honor a coin past its timelock (Second, a custom
+// captaind) "coinos holds my coins" is not a tier: whatever is stored, the
+// tier in force is "self" (or "exit" when chosen), the card offers only those
+// two, and the renewal pass registers backstops and renews what is due.
+// Real app with a local Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-foreign-test.js
+import assert from 'node:assert/strict';
+import puppeteer from '../puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-foreign-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, applyCustody, maybeAutoRefresh, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ // a custom server: no honorsExpired on its record, like Second's preset
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'custom');
+ localStorage.setItem('btc-wallet-ark-custom:regtest', JSON.stringify({ ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }));
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 288, ppm: 2000 }, { thresholdBlocks: 1008, ppm: 4000 }, { thresholdBlocks: 2016, ppm: 5000 }];
+ window.coopManager = {
+ honorsExpired: false,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 2 }, // weeks left
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 969760, depth: 1 }, // 9 blocks from its timelock
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 4032, roundIntervalSecs: 3600, refreshFees: { baseFeeSat: 0, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25000, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+
+ // nothing stored: the tier in force is "self", and the card offers no custodial option
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), null, 'nothing was written to the record');
+ const opts = await page.$$eval('#ark-custody-select option', (os) => os.map((o) => [o.value, o.selected]));
+ assert.deepEqual(opts, [['self', true], ['exit', false]]);
+ console.log('✓ a server that does not honor expired coins has no "coinos holds my coins" tier; "self" is in force');
+
+ // the stored default from a coinos wallet does not switch renewals off here
+ await page.evaluate(() => coopApp.wallet.saveFeatureState('arkCustody', 'custodial'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ console.log('✓ a stored "custodial" choice reads as "self" on such a server');
+
+ // the renewal pass runs: backstop orders are registered and the coin at its timelock renews now
+ const calls = await page.evaluate(async () => { coopFeature.autoRefreshCalls.length = 0; await coopFeature.maybeAutoRefresh(coopManager); return coopFeature.autoRefreshCalls; });
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin close to its timelock is renewed now');
+ console.log('✓ automatic renewal runs on such a server with nothing opted into');
+
+ // the exit tier can still be chosen, and is kept
+ await page.evaluate(() => coopFeature.applyCustody('exit'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), 'exit');
+ console.log('✓ "exit" can be chosen there and is saved');
+ assert.deepEqual(errors, [], 'no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..68e7191
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,151 @@
+// The custody tiers, without a network: what each policy aims for, how the
+// manager treats a coin past its timelock on a server that honors it, how
+// backstop orders snap to a grid so wallets share rounds, and that the
+// receive-side validator can be told to accept such a coin.
+// Run: bun tools/ark/custody-test.js
+import assert from 'node:assert/strict';
+import { hex } from '@scure/base';
+import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js';
+import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js';
+
+// policies
+assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'exit']);
+assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing');
+assert.equal(renewPolicy('nonsense', 'mainnet'), null);
+assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 });
+assert.deepEqual(renewPolicy('exit', 'mainnet'), { window: 96, margin: 36, grid: 48, maxDepth: 4 }, 'exit = self + a depth trigger');
+assert.deepEqual(renewPolicy('early', 'mainnet'), renewPolicy('exit', 'mainnet'), 'the old name still means the exit tier');
+assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 });
+// the snap never leaves the free bracket: under 96 blocks out
+for (let h = 100000; h < 100200; h++) { const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); }
+console.log('✓ renewal policies: custodial renews nothing; self and exit aim inside the free bracket');
+
+const mk = (honorsExpired) => {
+ const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired });
+ mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] };
+ mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } };
+ mgr._tipH = 1000;
+ return mgr;
+};
+const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra });
+
+// a coin past its timelock: unusable on a server that refuses it, money on one that honors it
+{
+ const strict = mk(false), lenient = mk(true);
+ for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)];
+ assert.equal(strict._expired(strict.state.vtxos[0], 1000), true);
+ assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored');
+ assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 });
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false);
+ assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 });
+ assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…');
+ assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed');
+ lenient.state.vtxos.push(coin('d', 9000, 900));
+ assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package');
+ lenient.state.vtxos.pop();
+ assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']);
+ // the server's word still wins: a refusal naming the coin excludes it
+ lenient.state.vtxos[0].expiryRejected = true;
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true);
+ assert.equal(lenient.balance().spendableSat, 7100);
+ console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it');
+}
+
+// the send cap is wider now that nothing consolidates on its own
+{
+ const m = mk(true);
+ m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000));
+ assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine');
+ m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000));
+ assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/);
+ console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`);
+}
+
+// backstop orders snap down to the grid, and skip what the snap would put in the past
+{
+ const m = mk(true);
+ const orders = [];
+ m._tipMemo = async () => 1000;
+ m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); };
+ m.state.vtxos = [
+ coin('x', 10000, 1100), // 1100-36 = 1064 → 1056
+ coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it)
+ coin('z', 30000, 1200), // 1164 → 1152
+ coin('w', 40000, 1200, { state: 'pending' }),
+ ];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2);
+ assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48');
+ assert.ok(orders.every(([, h]) => h % 48 === 0));
+ orders.length = 0;
+ m.state.scheduled = [];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights');
+ assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]);
+ console.log('✓ backstop orders snap to the grid (and never into the past)');
+}
+
+// the validator: an expired coin is refused by default, accepted when told the server honors it
+{
+ const serverPub = new Uint8Array(33).fill(2);
+ const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }],
+ expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' };
+ const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null };
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message));
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor');
+ console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check');
+}
+
+// deep coins: the server's depth cap and its base-fee waiver, mirrored
+{
+ const m = mk(true);
+ m.info.refreshFees.deepWaiverDepth = 60;
+ m.info.maxVtxoExitDepth = 100;
+ m.exitDepth = (v) => v.depth || 0;
+ m._tipMemo = async () => 1000;
+ const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 });
+ const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 });
+ const expiredDeep = coin('e', 6000, 900, { depth: 70 });
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee');
+ assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0);
+ assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays');
+ assert.equal(m.refreshFee([shallow], 1000), 300);
+ assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not');
+ assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)');
+ m.state.vtxos = [capped];
+ assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why');
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ // the worker's orders: one per deep coin still inside its timelock, two blocks out
+ const orders = [];
+ m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); };
+ assert.equal(await m.ensureDeepRefreshes(3), 3);
+ assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app');
+ assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice');
+ // no waiver and no cap advertised: nothing is deep
+ const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)];
+ assert.deepEqual(plain.deepCoins(), []);
+ assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep');
+ console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened');
+}
+
+// Lightning sends: a wallet that renews its own coins spends the nearest
+// expiry first; the custodial default keeps the smallest coin that covers it
+{
+ const m = mk(true);
+ m.info.lnSendFees = { baseFeeSat: 0, minFeeSat: 0, ppmExpiryTable: [] };
+ m.state.vtxos = [coin('young', 3000, 2000), coin('old', 9000, 1100), coin('older', 2000, 1050), coin('past', 8000, 900)];
+ const ids = (amt) => m._selectLnInputs(m.state.vtxos, 1000, amt, 0).inputs.map((v) => v.id);
+ assert.deepEqual(ids(2500), ['young'], 'custodial: the smallest coin that covers it');
+ assert.deepEqual(ids(12000), ['old', 'young'], 'custodial: largest first');
+ m.lnOldestFirst = () => true;
+ assert.deepEqual(ids(1500), ['older'], 'self: the nearest expiry that covers it');
+ assert.deepEqual(ids(2500), ['old'], '…skipping one too small');
+ assert.deepEqual(ids(10000), ['older', 'old'], 'self: gathers nearest expiry first');
+ assert.deepEqual(ids(13000), ['older', 'old', 'young'], 'coins inside their timelock carry it alone when they can');
+ // a pile of old crumbs that would breach the input cap: largest-first instead
+ m.state.vtxos = [...Array.from({ length: MAX_SEND_INPUTS + 2 }, (_, i) => coin('crumb' + i, 100, 1010)), coin('big', 6000, 3000)];
+ assert.deepEqual(ids(6050).slice(0, 1), ['big'], 'too many old crumbs for one payment: largest first');
+ assert.equal(ids(6050).length, 2);
+ console.log('✓ Lightning sends: nearest expiry first when renewing your own coins, smallest-that-covers otherwise');
+}
+
+console.log('\n✅ custody tiers behave');
diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js
new file mode 100644
index 0000000..9c238f5
--- /dev/null
+++ b/tools/ark/custody-ui-test.js
@@ -0,0 +1,136 @@
+// The custody card on the Spending settings page: three tiers, coinos holding
+// the coins by default, a coin past its timelock shown as held (not expired),
+// and switching tiers persists and reprices the page. Real app with a local
+// Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-ui-test.js
+import assert from 'node:assert/strict';
+import puppeteer from '../puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }];
+ window.coopManager = {
+ honorsExpired: true,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 5 }, // 29 days left, five hops deep
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000, depth: 1 }, // past its timelock
+ { id: 'coin-2', amountSat: 800, state: 'spendable', expiryHeight: 973921, depth: 6 }, // deep but not worth 300 sats
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25800, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return 300 + Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const text = () => page.evaluate(() => document.body.innerText);
+
+ let tx = await text();
+ assert.match(tx, /Custody/);
+ const opts = await page.$$eval('.ark-custody option', (es) => es.map((e) => e.value + '=' + e.textContent));
+ assert.deepEqual(opts, ['custodial=Allow coins to expire', 'self=Minimize renewal cost', 'exit=Minimize exit cost']);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), 'no fees');
+ assert.equal(await page.$eval('#ark-custody-select', (e) => e.value), 'custodial', 'coins expire by default');
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /keeps its own exit until it expires/);
+ assert.doesNotMatch(tx, /Coins renew in the last hours|Every transfer adds a hop/, 'only the chosen strategy is described');
+ assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card');
+ const order = await page.$$eval('.card h4, .card h3', (es) => es.map((e) => e.textContent).filter((x) => /Spending coins|Custody strategy|Renewal/.test(x)));
+ assert.deepEqual(order, ['Spending coins', 'Custody strategy'], 'coins first, then the strategy; no explainer cards');
+ assert.match(tx, /held by Coinos/, 'the coin past its timelock is held, not "expired"');
+ assert.doesNotMatch(tx, /5,000 sats\s+expired/, 'the row never says "expired"');
+ assert.doesNotMatch(tx, /nothing renews on its own/, 'the renewal explainer is not on the page…');
+ const renewSheet = async () => { await page.click('.ark-renew-ask'); await sleep(200); const t = await text(); await page.evaluate(() => [...document.querySelectorAll('.ark-renew-confirm button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); return t; };
+ assert.match(await renewSheet(), /With “Allow coins to expire”, nothing renews on its own[\s\S]*more than 15 days left/, '…it is on the Renew confirmation, with the fee schedule');
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true });
+ console.log('✓ the custody card: a drop-down of three strategies with their prices, coins expire by default, one description, a past-timelock coin shown as held');
+
+ // the unilateral exit only counts the coin still inside its timelock
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.textContent), 'Exit 20,800 sats', 'the exit button names the ticked coins still inside their timelock');
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.disabled), false);
+ await page.click('.ark-exit-ask'); await sleep(200);
+ tx = await text();
+ assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/);
+ assert.match(tx, /If the server vanished today/, 'the exit explainer is on the confirmation');
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200);
+ console.log('✓ a unilateral exit counts only the coin still inside its timelock');
+
+ // opt into keeping the exit: persisted, the page reprices, renewal starts covering
+ await page.select('#ark-custody-select', 'self'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet');
+ tx = await text();
+ assert.match(tx, /Custody choice saved/);
+ assert.match(await renewSheet(), /renews coins automatically once they are close to expiring/);
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /Coins renew in the last hours/);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats per renewal');
+ const calls = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1', 'coin-2']], 'the coin past its timelock is renewed now with the small change; the deep healthy one is left alone');
+ console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing');
+
+ // the exit tier also flattens the deep coin — but not the deep 800-sat one on its own merits
+ await page.evaluate(() => { coopFeature.autoRefreshCalls.length = 0; });
+ await page.select('#ark-custody-select', 'exit'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats near expiry, 0.2% + 300 sats when early');
+ const calls2 = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls2[1], ['refresh', ['coin-1', 'coin-0', 'coin-2']], 'expired + deep (worth it) + small change ride one round');
+ console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it');
+ await page.select('#ark-custody-select', 'custodial'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await renewSheet(), /nothing renews on its own/);
+ console.log('✓ the other tiers switch the same way, and back');
+ assert.deepEqual(errors, [], 'no page errors');
+ console.log('\n✅ custody is a choice, made on the Spending page');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/offboard-split-test.js b/tools/ark/offboard-split-test.js
new file mode 100644
index 0000000..561ee2b
--- /dev/null
+++ b/tools/ark/offboard-split-test.js
@@ -0,0 +1,128 @@
+// A partial on-chain send from Spending moves whole coins: the amount is
+// covered by coins that already add up to it, else split off via a self-send
+// that lands as one coin per input — all of them go, not one of the exact
+// size (Jarryd, 2026-10-23: three splits, no send). Local manager stub.
+// Run: bun tools/ark/offboard-split-test.js
+import assert from 'node:assert/strict';
+import puppeteer from '../puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'offboard-split-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, offboard: doArkOffboard, exactCoins };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'off');
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ window.coopCalls = [];
+ window.coopManager = {
+ state: { vtxos: [], actions: [], movements: [], nextKeyIndex: 5 },
+ async offboardQuote() { return { chainSat: 736, serviceSat: 204, totalSat: 940, minSat: 1270 }; },
+ info: { network: 'regtest' }, _tipH: 100,
+ vtxos() { return this.state.vtxos; },
+ refreshFee() { return 0; }, unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ // the real thing: largest inputs first until covered, one destination
+ // coin per input (the last carries the remainder), change on a new key;
+ // the destination coins land on the receive key at the next sync
+ async send(address, amount) {
+ coopCalls.push({ type: 'split', address, amount });
+ const free = this.state.vtxos.filter((v) => v.state === 'spendable').sort((a, b) => b.amountSat - a.amountSat);
+ const inputs = []; let sum = 0;
+ for (const v of free) { inputs.push(v); sum += v.amountSat; if (sum >= amount) break; }
+ let remaining = amount; const landing = [];
+ for (const v of inputs) {
+ const dest = Math.min(remaining, v.amountSat); remaining -= dest;
+ landing.push({ id: `dest-${v.id}`, amountSat: dest, state: 'spendable', keyIndex: 0 });
+ if (v.amountSat > dest) this.state.vtxos.push({ id: `change-${v.id}`, amountSat: v.amountSat - dest, state: 'spendable', keyIndex: this.state.nextKeyIndex++ });
+ v.state = 'spent';
+ }
+ this.pendingLanding = landing;
+ return 'send-1';
+ },
+ async sync() { if (this.pendingLanding) { this.state.vtxos.push(...this.pendingLanding); this.pendingLanding = null; } },
+ async startOffboard(spk, address, ids) {
+ coopCalls.push({ type: 'offboard', address, ids: ids || null });
+ return { txid: 'ab'.repeat(32), netSat: 1, feeSat: 1 };
+ },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.ui.screen = 'wallet'; coopApp.ui.tab = 'send'; coopApp.render();
+ }, generateMnemonic(wordlist));
+ const run = (coins, amount) => page.evaluate(async (coins, amount) => {
+ coopManager.state.vtxos = coins.map((c) => ({ state: 'spendable', keyIndex: 0, ...c }));
+ coopCalls.length = 0;
+ coopApp.ui.arkError = ''; coopApp.ui.arkOffboarded = null;
+ await coopFeature.offboard(amount, coopApp.wallet.derive(0, 1).address);
+ return { calls: coopCalls, err: coopApp.ui.arkError, done: !!coopApp.ui.arkOffboarded };
+ }, coins, amount);
+
+ // Jarryd's balance after one split: the halves add up, so nothing is split again
+ let r = await run([{ id: 'a', amountSat: 109958 }, { id: 'b', amountSat: 75042 }, { id: 'c', amountSat: 24958, keyIndex: 2 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['offboard']);
+ assert.deepEqual([...r.calls[0].ids].sort(), ['a', 'b']);
+ console.log('✓ coins that already add up to the amount go as they are, no split');
+
+ // one coin of the exact size
+ r = await run([{ id: 'a', amountSat: 109958 }, { id: 'c', amountSat: 24958 }], 24958);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: ['c'] }]);
+ console.log('✓ a single coin of the exact size is the one that goes');
+
+ // Jarryd's first attempt: 185,000 out of 100,000 + 109,958 — nothing adds
+ // up, so a self-send splits it off and lands as two coins; both go
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['split', 'offboard']);
+ assert.equal(r.calls[0].amount, 185000);
+ assert.deepEqual([...r.calls[1].ids].sort(), ['dest-a', 'dest-b']);
+ const left = await page.evaluate(() => coopManager.state.vtxos.filter((v) => v.state === 'spendable').map((v) => [v.id, v.amountSat, v.keyIndex]));
+ assert.deepEqual(left.filter(([id]) => id.startsWith('change')), [['change-a', 24958, 5]]);
+ console.log('✓ a split that lands as two coins sends both; the change stays behind');
+
+ // everything: no carving at all
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 0);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: null }]);
+ console.log('✓ the whole balance goes without a split');
+
+ // the subset search: prefers few large coins, gives up on no fit
+ const sub = await page.evaluate(() => {
+ const coins = (a) => a.map((amountSat, i) => ({ id: 'c' + i, amountSat }));
+ const pick = (a, n) => { const r = coopFeature.exactCoins(coins(a), n); return r && r.map((v) => v.amountSat).sort((x, y) => y - x); };
+ return [pick([5000, 3000, 2000, 1000, 1000], 6000), pick([5000, 3000], 7000), pick([5000, 3000, 2000], 5000), pick([7000, 3000], 3000)];
+ });
+ assert.deepEqual(sub, [[5000, 1000], null, [5000], [3000]]);
+ console.log('✓ exactCoins finds a subset, the single coin, or nothing');
+ assert.deepEqual(errors, []);
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/refresh-test.js b/tools/ark/refresh-test.js
index 0557659..6938daf 100644
--- a/tools/ark/refresh-test.js
+++ b/tools/ark/refresh-test.js
@@ -29,6 +29,7 @@ import {
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 FUND_SAT = 12345;
const SPEND_SAT = 3000;
@@ -86,7 +87,13 @@ if (!status?.fundingTx) { console.error('round never executed'); process.exit(1)
const fundingTx = parseTx(status.fundingTx);
console.log(`round executed: funding tx ${fundingTx.txid}, ${status.outputVtxos.length} output vtxo(s)`);
execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' });
-await sleep(2000);
+// alice's bark checks the round anchor against esplora, which indexes a block
+// ~5 s after it's mined; hand her the coin before then and she drops it
+for (let i = 0; i < 30; i++) {
+ const st = await fetch(`${ESPLORA}/tx/${fundingTx.txid}/status`).then((r) => r.ok && r.json()).catch(() => null);
+ if (st?.confirmed) break;
+ await sleep(1000);
+}
const newVtxo = decodeVtxo(status.outputVtxos[0]);
console.log(`new vtxo: ${newVtxo.id} (${newVtxo.amountSat} sat, expiry ${newVtxo.expiryHeight}, transitions: ${newVtxo.genesis.map((g) => g.transition.type).join(' -> ')})`);
diff --git a/tools/call-test.js b/tools/call-test.js
new file mode 100644
index 0000000..d5c3909
--- /dev/null
+++ b/tools/call-test.js
@@ -0,0 +1,237 @@
+// 1:1 calls (NIP-AC): two wallets, each in its own browser tab, a relay
+// between them. The caller's 📞 / 🎥 in their conversation rings the other
+// over whatever page they're on; Answer connects them — each hears and sees
+// the other — mute and camera-off reach the other side, hang-up ends it on
+// both. A call can be declined, and a stranger's (neither followed nor
+// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap.
+// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+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 PORT = 5305;
+const SLOW = +(process.env.SLOW || 0);
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+
+// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches
+const stored = [], subs = new Set(), kindsSeen = [], wrapIds = new Set();
+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.since || e.created_at >= f.since)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ // two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay
+ // lookups, take as long as they do out there)
+ if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); }
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = { subs: new Map() }; },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1]; kindsSeen.push(e.kind); if (e.kind === 21059) wrapIds.add(e.id);
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; }
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') { const s = ws.data.subs.get(m[1]); if (s) subs.delete(s); }
+ },
+ close(ws) { for (const s of ws.data.subs.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
+ '--auto-select-desktop-capture-source=Entire screen'] }); // a screen to share, chosen without asking
+// our DM relays lead to the test's relay
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const page = await (await browser.createBrowserContext()).newPage(); // a browser of its own: its own storage
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const follow = (w, pks, profiles) => w.page.evaluate(([k, pks, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+}, [w.base, pks, profiles]);
+// the call's pictures and sound actually moving: frames decoded, bytes in
+const media = (page) => page.evaluate(async () => {
+ const v = document.querySelector('video.call-remote'), a = document.querySelector('audio.call-audio');
+ const s = a && a.srcObject;
+ return { video: !!v && v.videoWidth > 0 && !v.paused, audio: !!s && s.getAudioTracks().some((t) => t.readyState === 'live'), local: !!document.querySelector('video.call-local')?.videoWidth };
+});
+
+try {
+ const A = await wallet('alice'), B = await wallet('bob'), C = await wallet('carol');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() }, [C.pk]: { name: 'Carol Stranger', t: Date.now() } };
+ await follow(A, [B.pk], profiles); await follow(B, [A.pk], profiles); await follow(C, [B.pk], profiles);
+ for (const w of [A, B, C]) { await w.page.reload({ waitUntil: 'domcontentloaded' }); await w.waitText('receive'); }
+ const openDm = async (w, peer) => {
+ await w.page.goto(`http://localhost:${PORT}/` + npubEncode(peer), { waitUntil: 'domcontentloaded' });
+ await w.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await w.page.evaluate(() => { const b = [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)); b.click(); });
+ await w.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ };
+
+ console.log('[a video call]');
+ await openDm(A, B.pk);
+ check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
+ const w0 = wrapIds.size;
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
+ check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
+ check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059));
+ if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone
+ const tAccept = Date.now();
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ const answerMs = Date.now() - tAccept;
+ check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ await sleep(1500);
+ const ma = await media(A.page), mb = await media(B.page);
+ check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
+ // the routes ride in the offer and answer: a household's two devices
+ // calling each other mustn't flood the relays (a per-IP limit ate the answer)
+ check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps');
+ await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
+ check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());
+ check('minimized: a bar across the top, the page usable under it', await waitFor(A.page, () => !!document.querySelector('.call-min-bar') && !document.querySelector('.call-bar'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-min-bar').click());
+ await waitFor(A.page, () => !!document.querySelector('.call-hang'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ check('hanging up ends it here', await waitFor(A.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 3000));
+ check('...and there', await waitFor(B.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ check('...the cameras let go', await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000) && await waitFor(B.page, () => !document.querySelector('.call-overlay'), 6000));
+ const logs = (page) => page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText.replace(/\s+/g, ' ').trim()));
+ check('the call is in the caller\'s conversation: outgoing, how long', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing video call · \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(A.page)));
+ await openDm(B, A.pk);
+ check('...and in the callee\'s: incoming', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Incoming video call · \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(B.page)));
+
+ console.log('\n[declined]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ check('a voice call rings', await waitFor(B.page, () => /voice call/i.test(document.querySelector('.call-overlay')?.innerText || ''), 15000));
+ await B.page.evaluate(() => document.querySelector('.call-decline').click());
+ check('declined: the caller is told', await waitFor(A.page, () => /declined/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000);
+ check('...and it\'s logged so on both sides', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing voice call · Declined/.test(r.innerText)), 5000)
+ && await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Declined voice call/.test(r.innerText)), 5000), JSON.stringify([await logs(A.page), await logs(B.page)]));
+ await B.page.evaluate(() => document.querySelector('.chat-row.call-log .call-log-pill').click());
+ check('a tap on a logged call calls back', await waitFor(A.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000));
+ await A.page.evaluate(() => document.querySelector('.call-decline').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+
+ console.log('\n[a voice call turned video]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ const live = (p) => waitFor(p, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ check('a voice call connects, no cameras', await live(A.page) && await live(B.page) && !(await A.page.evaluate(() => !!document.querySelector('video.call-local'))));
+ check('...each side offered Turn on camera', await waitFor(A.page, () => !!document.querySelector('.call-add-video'), 3000) && await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-add-video').click());
+ check('Alice turns her camera on: she sees herself, and gets camera off and flip', await waitFor(A.page, () => !!document.querySelector('video.call-local')?.videoWidth && !document.querySelector('.call-add-video')
+ && !!document.querySelector('.call-btn[aria-label="Switch camera"]'), 10000));
+ check('...Bob sees her', await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000), JSON.stringify(await media(B.page)));
+ check('...and can turn his on too', await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
+ await B.page.evaluate(() => document.querySelector('.call-add-video').click());
+ check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000)
+ && (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page)));
+
+ console.log('\n[sharing a screen]');
+ const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; });
+ const camDims = await dims(B.page);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000));
+ check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await live(A.page); await live(B.page);
+ await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000)
+ && await live(B.page) && (await media(B.page)).audio);
+ check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local')));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+
+ console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]');
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0'));
+ const before = wrapIds.size;
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-accept'), 20000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('it connects', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000));
+ await sleep(1500);
+ const m2 = await media(A.page);
+ check('...picture and sound', m2.video && m2.audio, JSON.stringify(m2));
+ const sent = wrapIds.size - before;
+ check('...with a handful of messages, not one per candidate (offer, answer, its copy for my other devices)', sent <= 3, sent + ' wraps');
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.removeItem('coinos-call-trickle'));
+
+ console.log('\n[a stranger]');
+ await openDm(C, B.pk);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(5000);
+ check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ await waitFor(C.page, () => !document.querySelector('.call-overlay'), 6000);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(4000);
+ check('...nor does their next call (a logged call isn\'t having written to them)', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ await openDm(B, C.pk);
+ check('...but it\'s in his conversation with them, as a call that didn\'t ring, and why', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText) && /people you follow or have written to/i.test(r.innerText)), 8000),
+ JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
+ check('...the caller who gave up has it as cancelled', await waitFor(C.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Cancelled voice call/.test(r.innerText)), 6000));
+ for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ calls: rung, answered, seen and heard, ended' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js
new file mode 100644
index 0000000..54c42ee
--- /dev/null
+++ b/tools/call-wake-test.js
@@ -0,0 +1,214 @@
+// A call wakes a closed app: with Bob's coinos closed, Alice calls; the push
+// the notifier would send (delivered here to Bob's service worker through
+// Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open
+// it with Bob's key and ring — "Alice Caller is calling" — and the tap that
+// opens the app finds the offer kept for it: it rings there, Bob answers,
+// they talk. A push for a stranger's call, or for something that isn't an
+// offer, rings nothing. Plus the worker's check on its own (classifyCall).
+// Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { bytesToHex } from '@noble/hashes/utils';
+import { npubEncode } from 'nostr-tools/nip19';
+import { classifyCall } from '../src/dm-inbox.js';
+import { callEvent, wrapCall, CALL } from '../src/calls.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 PORT = 5306;
+
+console.log('[the worker\'s check]');
+{
+ const bob = generateSecretKey(), alice = generateSecretKey(), eve = generateSecretKey();
+ const B = getPublicKey(bob), Al = getPublicKey(alice), E = getPublicKey(eve);
+ const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }];
+ const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to);
+ const r = await classifyCall(offer(alice), box);
+ check('a fresh offer from someone followed rings, named, video', !!r && !r.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al);
+ check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true);
+ check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null);
+ check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false);
+ check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true);
+ check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null);
+ check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null);
+ check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null);
+}
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const stored = [], subs = new Set(), callWraps = new Map();
+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))
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 });
+ const w = /^\/nwcpush\/wrap\/([0-9a-f]{64})$/.exec(path); // the notifier's keeping of call wraps
+ if (w) return callWraps.has(w[1]) ? Response.json(callWraps.get(w[1])) : Response.json({ error: 'gone' }, { status: 404 });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = new Map(); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1];
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (e.kind === 21059) callWraps.set(e.id, e); else if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') subs.delete(ws.data.get(m[1]));
+ },
+ close(ws) { for (const s of ws.data.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const context = await browser.createBrowserContext();
+ await context.overridePermissions(`http://localhost:${PORT}`, ['notifications']);
+ const page = await context.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ const sk = privateKeyFromSeedWords(mn);
+ return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+// the push the notifier sends, delivered to a context's service worker
+async function push(w, data) {
+ const cdp = await w.page.target().createCDPSession();
+ const regs = [];
+ cdp.on('ServiceWorker.workerRegistrationUpdated', (e) => regs.push(...e.registrations));
+ await cdp.send('ServiceWorker.enable');
+ for (let i = 0; i < 25 && !regs.some((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); i++) await sleep(200);
+ const reg = regs.find((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted);
+ await cdp.send('ServiceWorker.deliverPushMessage', { origin: `http://localhost:${PORT}`, registrationId: reg.registrationId, data: JSON.stringify(data) });
+ await cdp.detach();
+}
+const notifications = (page) => page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map((n) => n.title + ' | ' + n.body + ' | ' + n.tag));
+
+try {
+ console.log('\n[a closed app, rung]');
+ const A = await wallet('alice'), B = await wallet('bob');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() } };
+ // their follow lists, where the app (and Bob's worker mirror) read them
+ for (const [w, other] of [[A, B], [B, A]]) {
+ // (newer than the starter list a new wallet publishes for itself)
+ stored.push(finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000) + 120, content: '', tags: [['p', other.pk]] }, w.sk));
+ await w.page.evaluate(([k, pk, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+ }, [w.base, other.pk, profiles]);
+ await w.page.reload({ waitUntil: 'domcontentloaded' });
+ await w.waitText('receive');
+ }
+ check('Bob\'s app has given his worker what it needs: his key, his follows', await waitFor(B.page, (pk) => new Promise((res) => {
+ const r = indexedDB.open('coinos-dm-inbox');
+ r.onsuccess = () => { try { const g = r.result.transaction('inbox').objectStore('inbox').getAll(); g.onsuccess = () => res(g.result.some((x) => x.sk && (x.follows || []).includes(pk))); g.onerror = () => res(false); } catch { res(false); } };
+ r.onerror = () => res(false);
+ }), 20000, A.pk));
+ await B.page.evaluate(() => navigator.serviceWorker.ready);
+ // Bob closes coinos
+ await B.page.goto('about:blank');
+ // Alice calls
+ await A.page.goto(`http://localhost:${PORT}/` + npubEncode(B.pk), { waitUntil: 'domcontentloaded' });
+ await A.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await A.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ await A.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ const wrapsBefore = new Set(callWraps.keys());
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ let offerWrap = null;
+ for (let i = 0; i < 50 && !offerWrap; i++) { await sleep(200); offerWrap = [...callWraps.values()].find((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
+ check('Alice\'s offer goes out, big enough for the notifier to wake Bob for', !!offerWrap, offerWrap && offerWrap.content.length + ' chars');
+ const small = [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.content.length < 2000);
+ check('...and what follows it is small enough not to', small.length >= 1 && small.every((w) => w.content.length < 1500), small.map((w) => w.content.length).join(','));
+ await push(B, { type: 'notify', reason: 'call', wrapId: offerWrap.id });
+ await sleep(1500);
+ // the tap: the worker opens the app at ?open=call
+ await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' });
+ const ns = await notifications(B.page);
+ check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns));
+ check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000));
+ // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one)
+ const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length;
+ const ringingCands = toBob();
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('Bob answers: they\'re connected', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000)
+ && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ check('Alice sends her candidates again once answered (Bob\'s app heard none of the first ones)', toBob() >= 2 * ringingCands && ringingCands > 0, ringingCands + ' → ' + toBob());
+ check('...and hear each other', await waitFor(B.page, () => { const a = document.querySelector('audio.call-audio'); return !!a && !!a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live'); }, 5000));
+ check('the "is calling" notification is gone once answered', await waitFor(B.page, async () => !(await (await navigator.serviceWorker.ready).getNotifications({ tag: 'call' })).length, 5000));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+
+ console.log('\n[pushes that ring nothing]');
+ await B.page.goto('about:blank');
+ const eve = generateSecretKey();
+ const strangers = wrapCall(finalizeEvent(callEvent(CALL.OFFER, B.pk, 'x-1', 'v=0\r\n' + 'a'.repeat(2400), [['call-type', 'voice']]), eve), B.pk);
+ callWraps.set(strangers.id, strangers);
+ await push(B, { type: 'notify', reason: 'call', wrapId: strangers.id });
+ await push(B, { type: 'notify', reason: 'call', wrapId: 'f'.repeat(64) }); // one the notifier no longer has
+ await sleep(1500);
+ await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await B.waitText('receive');
+ check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' });
+ await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000));
+
+ console.log('\n[rung while closed, and nobody came]');
+ await B.page.goto('about:blank');
+ const before2 = new Set(callWraps.keys());
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ let offer2 = null;
+ for (let i = 0; i < 50 && !offer2; i++) { await sleep(200); offer2 = [...callWraps.values()].find((w) => !before2.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
+ await push(B, { type: 'notify', reason: 'call', wrapId: offer2.id });
+ await sleep(1500);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click()); // Alice gives up
+ // Bob opens coinos later on, past the call's minute (the kept offer dated back)
+ const page2 = await B.context.newPage();
+ await page2.goto(`http://localhost:${PORT}/icon-192.png`, { waitUntil: 'domcontentloaded' }); // the origin, without the app (which would take the offer)
+ await page2.evaluate(async () => {
+ const c = await caches.open('coinos-call'), r = await c.match('/__pending-call');
+ const j = await r.json(); j.at -= 120_000;
+ await c.put('/__pending-call', new Response(JSON.stringify(j), { headers: { 'content-type': 'application/json' } }));
+ });
+ await page2.close();
+ await B.page.goto(`http://localhost:${PORT}/` + npubEncode(A.pk), { waitUntil: 'domcontentloaded' });
+ check('the app doesn\'t ring for a call long over', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ check('...it\'s a missed call in the conversation', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText)), 10000),
+ JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
+ for (const w of [A, B]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ a call wakes a closed app' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js
index 512b8bd..94b343a 100644
--- a/tools/composer-media-test.js
+++ b/tools/composer-media-test.js
@@ -18,7 +18,7 @@ const page = await browser.newPage();
const uploads = [];
await page.setRequestInterception(true);
// every public media host the upload may try first (nothing leaves the machine)
-const HOSTS = /blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
+const HOSTS = /media\.example|blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
page.on('request', (req) => {
const u = req.url();
if (HOSTS.test(u) && req.method() === 'PUT') {
@@ -46,6 +46,23 @@ try {
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click());
+ await sleep(300);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find(e => e.textContent.includes('Nostr')).click());
+ await page.waitForSelector('[data-key="media-servers"] textarea');
+ const setServers = async (value) => page.evaluate(value => {
+ const card = document.querySelector('[data-key="media-servers"]');
+ const input = card.querySelector('textarea'); input.value = value;
+ input.dispatchEvent(new Event('input', {bubbles:true}));
+ card.querySelector('button').click();
+ }, value);
+ await setServers('https://media.example/');
+ check('settings save a normalized custom server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await setServers('https://media.example/path');
+ check('invalid settings preserve the previous server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await page.evaluate(() => document.querySelectorAll('[data-key="media-servers"] button')[1].click());
+ check('Use default clears the override', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === null));
+ await setServers('https://media.example');
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9';
await page.evaluate(([k, a]) => {
@@ -91,7 +108,7 @@ try {
document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
});
const landed = await waitFor(() => /nostr\.download\/f{64}\.gif/.test(document.querySelector('.thread-reply-input')?.value || ''), 15000);
- check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1,
+ check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1 && uploads[0] === 'https://media.example/upload',
JSON.stringify(await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)) + ' uploads=' + uploads.length);
check('...and the preview shows it', await waitFor(() => !!document.querySelector('.draft-preview img, .draft-preview .note-img, .draft-preview a'), 5000));
// the upload re-rendered the page: the field must still be editable
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-boot-chrome-test.js b/tools/feed-boot-chrome-test.js
new file mode 100644
index 0000000..91e5188
--- /dev/null
+++ b/tools/feed-boot-chrome-test.js
@@ -0,0 +1,113 @@
+// The feed opened by URL (/feed) paints as the restoring wallet's from its
+// first frame. It painted as a visitor's — no post button, chips or nav —
+// until the restore landed a beat later (the vault's scrypt, the wallet's
+// state, every feature's init: ~0.5 s on a laptop, more on a phone), and
+// the button "took a while to appear" (Adam, 2026-10-31). Boot now tells
+// a wallet is about to open (a session list in the tab, a vault the last
+// unlock opened without a password, watch-only accounts) before the work,
+// and the page paints for it. A visitor with no wallet still gets none.
+// Run: bun tools/feed-boot-chrome-test.js
+import puppeteer from './puppeteer.js';
+import { buildHtml } from '../build.js';
+import { encryptVault } from '../src/wallet.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+let fails = 0;
+const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); if (!ok) fails++; };
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5331, fetch: (req) => {
+ const p = new URL(req.url).pathname;
+ if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p));
+ 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 mnemonic = generateMnemonic(wordlist);
+const account = { id: 'boot-test', label: 'Boot', type: 'full', mnemonic, passphrase: '', network: 'regtest', persisted: true };
+const vault = encryptVault([{ label: account.label, mnemonic, passphrase: '', network: 'regtest' }], '');
+
+// Every mutation batch gets a number; the first batch each piece of the page
+// showed up in says whether it came with the page or after it.
+const watch = () => {
+ window.__seen = {}; window.__batch = 0;
+ const SEL = { page: '.feed-page', fab: '.feed-post-fab', chips: '.feed-chips', nav: '.app-bottom-nav', side: '.wallet-side' };
+ new MutationObserver(() => {
+ window.__batch++;
+ for (const [k, s] of Object.entries(SEL)) if (!(k in window.__seen) && document.querySelector(s)) { window.__seen[k] = { batch: window.__batch, at: Math.round(performance.now()) }; if (k === 'fab') window.__fab0 = document.querySelector(s); }
+ }).observe(document, { childList: true, subtree: true });
+};
+async function load(name, seed) {
+ const ctx = await browser.createBrowserContext(); // its own storage: each scenario starts clean
+ const page = await ctx.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(watch);
+ await page.evaluateOnNewDocument(seed, { account, vault });
+ await page.goto('http://localhost:5331/feed', { waitUntil: 'domcontentloaded' });
+ await sleep(3500);
+ const r = await page.evaluate(() => ({
+ seen: window.__seen, errors: [],
+ fabSame: !!window.__fab0 && document.querySelector('.feed-post-fab') === window.__fab0,
+ chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim() + (c.classList.contains('on') ? '*' : '')),
+ fab: !!document.querySelector('.feed-post-fab'), nav: !!document.querySelector('.app-bottom-nav'),
+ walletOpen: !!document.querySelector('.app-nav-profile, .chat-avatar'),
+ blankNote: localStorage.getItem('btc-wallet-vault-blank'),
+ text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 120),
+ }));
+ r.errors = errors;
+ console.log(`[${name}] ${JSON.stringify(r.seen)} chips=${r.chips.join('|')}`);
+ await ctx.close();
+ return r;
+}
+// with the page, or (the nav rides the boot shell too) before it
+const withPage = (r, k) => r.seen[k] && r.seen.page && r.seen[k].batch <= r.seen.page.batch;
+
+try {
+ // 1. A reload: the tab's session list says which wallet is coming.
+ let r = await load('session', ({ account }) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id }));
+ });
+ check('session: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen));
+ check('session: the chips paint with the page', withPage(r, 'chips'));
+ check('session: the nav paints with the page', withPage(r, 'nav'));
+ check('session: the wallet then opens behind it', r.fab && r.nav && r.walletOpen, r.text);
+ check('session: the button is the same node through the restore (no blink)', r.fabSame);
+ check('session: Popular stood for the visitor copy, then the wallet\'s own chips', r.chips.length >= 2 && r.chips.filter((c) => /Popular/.test(c)).length === 1, r.chips.join('|'));
+ check('session: no page errors', r.errors.length === 0, r.errors.join(' | '));
+
+ // 2. A cold start: a vault the last unlock opened without a password.
+ r = await load('vault', ({ vault }) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-vault', JSON.stringify(vault));
+ localStorage.setItem('btc-wallet-vault-blank', '1');
+ });
+ check('vault: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen));
+ check('vault: the nav paints with the page', withPage(r, 'nav'));
+ check('vault: the wallet opens behind it', r.fab && r.walletOpen, r.text);
+ check('vault: the same node through the scrypt restore', r.fabSame);
+ check('vault: no page errors', r.errors.length === 0, r.errors.join(' | '));
+
+ // 3. The same vault before any unlock left its note: boot can't tell, so
+ // the page paints as a visitor's and grows the button when the wallet
+ // opens — and the unlock leaves the note for next time.
+ r = await load('vault, no note', ({ vault }) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-vault', JSON.stringify(vault));
+ });
+ check('unnoted vault: the button still comes, after the page', r.fab && r.seen.fab && r.seen.page && r.seen.fab.batch > r.seen.page.batch, JSON.stringify(r.seen));
+ check('unnoted vault: the unlock leaves the note', r.blankNote === '1');
+
+ // 4. A visitor: no wallet on this device, nothing to paint for.
+ r = await load('visitor', () => { localStorage.setItem('btc-wallet-network', 'regtest'); });
+ check('visitor: the feed paints, without a post button', r.seen.page && !r.fab && !r.seen.fab, JSON.stringify(r.seen));
+ check('visitor: no page errors', r.errors.length === 0, r.errors.join(' | '));
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ the feed paints for the wallet that is coming');
+process.exit(fails ? 1 : 0);
diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js
new file mode 100644
index 0000000..f16fc35
--- /dev/null
+++ b/tools/feed-conversations-test.js
@@ -0,0 +1,134 @@
+// 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 live=Live 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))));
+ // (a "Couldn't find that note" card over it told nobody anything: Adam, 2026-10-20)
+ check('a parent nobody has: the reply leaves the feed once every lookup comes back without it', await waitFor((ids) => !document.querySelector('.note-post[data-zap-post="' + ids[0] + '"]') && !!document.querySelector('.note-post[data-zap-post="' + ids[1] + '"]'), [R2.id, R1.id], 12000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ // 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 === 1 && 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 === 1 && got[0] === ids[0]; }, [R1.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-cache-test.js b/tools/feed-media-cache-test.js
index eee88c5..5a26ca2 100644
--- a/tools/feed-media-cache-test.js
+++ b/tools/feed-media-cache-test.js
@@ -14,7 +14,9 @@ let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/mcache-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
-for (const n of ['v1', 'v2', 'v3', 'h4', 'n5', 'r6']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// (six different files: one person's clips of one size to the byte are taken
+// for one clip uploaded twice)
+for (const [i, n] of ['v1', 'v2', 'v3', 'h4', 'n5', 'r6'].entries()) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', String(2 + i / 4), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/slow.jpg`]);
const html = await buildHtml({ minify: true, pwa: false });
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 2c84b14..dac9804 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -16,7 +16,9 @@ let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/media-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
-for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// (three different files: one person's clips of one size to the byte are
+// taken for one clip uploaded twice)
+for (const [n, t] of [['a', '2'], ['b', '2.5'], ['c', '3']]) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', t, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
// index last (no faststart), from a server that ignores ranges
ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
@@ -95,7 +97,8 @@ const pagerState = () => page.evaluate(() => {
const p = document.querySelector('.media-pager');
if (!p) return null;
const sc = p.querySelector('.mp-scroll');
- const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ // (not the loading screen at the end, nor a clip held out for its shape)
+ const slides = [...sc.querySelectorAll('.mp-slide')].filter((x) => !x.closest('.mp-off'));
const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
const vids = slides.map((s) => s.querySelector('video'));
return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
@@ -126,10 +129,16 @@ try {
await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
console.log('\n[the switch]');
- check('All / Images / Videos / Articles sit under the chips, All on', await page.evaluate(() =>
- [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos,Articles'));
+ check('Notes / Conversations / Images / Videos / Live / 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,Live,Articles'));
+ // five chips are wider than a phone: the strip scrolls sideways, the page does not get wider
+ check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => {
+ const s = document.querySelector('.media-modes');
+ return s.scrollWidth > s.clientWidth && getComputedStyle(s).overflowX === 'auto' && document.documentElement.scrollWidth <= innerWidth;
+ }), await page.evaluate(() => 'page ' + document.documentElement.scrollWidth + ' of ' + innerWidth));
console.log('\n[Images: a grid]');
+ await page.evaluate(() => localStorage.setItem('coinos-clips-fit', 'all')); // every shape: these pictures are wide (the Tall only filter is Videos' to show)
await mode('images');
check('the feed becomes a grid of its pictures, one tile each', await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 3, 8000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length)));
@@ -156,6 +165,7 @@ try {
check('Back closes the pager, leaving the grid', await page.evaluate(() => !document.querySelector('.media-pager') && !!document.querySelector('.media-grid')));
check('...and the page scrolls again', await page.evaluate(() => !document.documentElement.classList.contains('no-scroll')));
+ await page.evaluate(() => localStorage.removeItem('coinos-clips-fit')); // the screen's way round again
console.log('\n[the big clip in the feed]');
await mode('all'); await sleep(800);
await page.evaluate(() => { const v = [...document.querySelectorAll('.note-video-box video')].find((x) => /c\.mp4/.test(x.getAttribute('src') || x.getAttribute('data-lazy-src') || '')); v.scrollIntoView({ block: 'center' }); });
@@ -186,22 +196,22 @@ try {
check('...another plays it on, the details down', st.playing[0] && !st.show, JSON.stringify(st));
await sleep(1000);
let ss = await srcs();
- // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own)
- check('only the clip on screen has its file; the rest nothing yet',
- ss[0].src && ss[0].pre === 'auto' && !ss[1].src && !ss[2].src, JSON.stringify(ss));
+ // (the next gets a warm player, read to its first frame — pager-eager-test; the one after, its opening in the cache only)
+ check('the clip on screen has its file, the next a warm player, the one after nothing yet',
+ ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss));
await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000);
await sleep(1500);
const vi = await info();
check('a probe reads the size, ranges, layout and picture size from 64 KB', vi['a.mp4']?.probed && vi['a.mp4'].ranges && vi['a.mp4'].faststart
&& vi['a.mp4'].size > 1000 && vi['a.mp4'].w === 360 && vi['a.mp4'].h === 640, JSON.stringify(vi['a.mp4']));
- check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src'))))));
+ check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src')) && !v.closest('.mp-off')))));
check('an index-last file from a server without ranges is seen for what it is', vi['slow.mp4']?.probed && vi['slow.mp4'].faststart === false && vi['slow.mp4'].ranges === false, JSON.stringify(vi['slow.mp4']));
check('the 50 MB clip is known as such before any of it plays', vi['c.mp4']?.size === 50000000, JSON.stringify(vi['c.mp4']));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st));
ss = await srcs();
- check('...and the window moves with it: the first lets go, the big one has no player yet', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss));
+ check('...and the window moves with it: the first keeps its player (for the swipe back), the big one next is warm', ss[0].src && ss[1].pre === 'auto' && ss[2].src && ss[2].pre === 'metadata', JSON.stringify(ss));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
check('flipping is asking to watch: the 50 MB clip plays when it comes up', st.src === 'c.mp4' && st.playing[2] && await page.evaluate(() => !document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy')), JSON.stringify(st));
diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js
index 29e4934..3debd8a 100644
--- a/tools/feed-momentum-test.js
+++ b/tools/feed-momentum-test.js
@@ -1,8 +1,7 @@
// Desktop feed scrolling feels like a phone: a mouse-wheel notch coasts
// farther than the browser's own step, quick notches stack, a reverse turn
// brakes; a trackpad's smooth deltas are left to the browser; a mouse drag
-// scrolls and a release while moving throws it; a drag that moved doesn't
-// click; a sideways drag is left alone (text selection).
+// selects text, as on any page.
// Uses a tall stand-in feed so no relays are needed.
// Run: bun tools/feed-momentum-test.js
import puppeteer from './puppeteer.js';
@@ -76,50 +75,17 @@ try {
check('...and a notch right after them too (still a trackpad)', !(await notch(1)));
await sleep(500);
- console.log('[grab and flick]');
+ console.log('[a mouse drag selects text]');
await top();
+ const b = await (await page.$('#tgt')).boundingBox();
+ await page.evaluate(() => { window.__clicks = 0; getSelection().removeAllRanges(); });
await page.mouse.move(300, 700); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 40); await sleep(8); }
- const during = await y();
await page.mouse.up();
- check('dragging up scrolls with the mouse', during >= 380 && during <= 420, during + 'px');
- const flung = await settle();
- check('letting go while moving throws it', flung > during + 400, flung + 'px');
- await top();
- await page.mouse.move(300, 700); await page.mouse.down();
- for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 30); await sleep(8); }
- await sleep(250); // hold still
- const held = await y();
- await page.mouse.up();
- check('held still before letting go: it stays put', (await settle()) - held < 5, held + ' → ' + await y());
- await top();
- const b = await (await page.$('#tgt')).boundingBox();
- await page.evaluate(() => { window.__clicks = 0; });
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 50, b.y + 150 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a drag that started on a button doesn\'t click it', await page.evaluate(() => window.__clicks) === 0);
- // a hand's flick often starts a little sideways
- await top();
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- await page.mouse.move(b.x + 58, b.y + 145);
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 60, b.y + 145 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a flick that starts a bit sideways still scrolls', (await settle()) > 50, await y() + 'px');
- check('...and doesn\'t click', await page.evaluate(() => window.__clicks) === 0);
- await top();
- await page.mouse.move(b.x + 20, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 8; i++) await page.mouse.move(b.x + 20 + i * 30, b.y + 152);
- await page.mouse.up(); await sleep(50);
- check('a sideways drag (selecting) doesn\'t click either', await page.evaluate(() => window.__clicks) === 0);
- await sleep(350);
+ check('dragging up doesn\'t scroll the feed', (await settle()) === 0, await y() + 'px');
+ check('...it selects text', await page.evaluate(() => getSelection().toString().length > 0));
await page.click('#tgt'); await sleep(50);
- check('...but a plain click still does', await page.evaluate(() => window.__clicks) === 1);
- await top();
- await page.mouse.move(100, 500); await page.mouse.down();
- for (let i = 1; i <= 10; i++) await page.mouse.move(100 + i * 30, 505);
- await page.mouse.up();
- check('a sideways drag doesn\'t scroll (it selects text)', (await settle()) === 0);
+ check('a plain click still clicks', await page.evaluate(() => window.__clicks) === 1);
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
console.log(ok ? 'PASS' : 'FAIL');
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
index 35b8e84..59d6f7c 100644
--- a/tools/feed-new-posts-test.js
+++ b/tools/feed-new-posts-test.js
@@ -99,21 +99,19 @@ try {
assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]')),'a post barely on screen starts fading too');
await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen');
- // Only ever up: reading a new post that's newer than the oldest one, the
- // pill counts just the ones above you and the tap goes up to the nearest.
+ // Starting upward hides the pill before any new post is reached.
await test_reset();
await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading);
await sleep(300);
await page.evaluate(()=>test.arrive(3)); await sleep(300);
- await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},F(1));
- await sleep(400);
- assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'1','scrolled up past two new posts: one left above');
- const yUp=await page.evaluate(()=>scrollY);
- await page.tap('.feed-new-pill'); await sleep(400);
- assert(await page.evaluate(()=>scrollY)<yUp,'the tap went up, not down to the oldest new post');
- const newest=await top(F(0));
- assert(newest>=0&&newest<=20,'...landing on the new post above: '+newest);
- await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
+ assert(await page.$('.feed-new-pill'),'arrivals show the pill');
+ await page.evaluate(()=>window.scrollBy(0,40)); await sleep(300);
+ assert(await page.$('.feed-new-pill'),'scrolling down leaves the pill visible');
+ await page.evaluate(()=>window.scrollBy(0,-10)); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'a small upward scroll hides the pill before reaching new posts');
+ assert((await top(F(2)))<0,'new posts are still above the viewport');
+ await page.evaluate(()=>{window.scrollBy(0,20);test.render();}); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'scrolling down and repainting do not revive the notice');
// swipe the pill away: it goes, the page doesn't move, and it's back once more arrive
await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading);
await sleep(300);
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-video-pager-test.js b/tools/feed-video-pager-test.js
index add9208..61119a0 100644
--- a/tools/feed-video-pager-test.js
+++ b/tools/feed-video-pager-test.js
@@ -67,6 +67,8 @@ 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);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
@@ -120,6 +122,9 @@ try {
check('the seek bar sits clear of the bottom edge', geo.bottom >= 32, JSON.stringify(geo));
check('…and in from the sides', geo.left >= 16 && geo.right >= 16, JSON.stringify(geo));
if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.media-pager').classList.add('show')); await sleep(300); await page.screenshot({ path: process.env.SHOT }); await page.evaluate(() => document.querySelector('.media-pager').classList.remove('show')); }
+ // (out of sight while it plays: a tap near the foot brings it up)
+ { const r = await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y - 30 }; }, '(' + CUR + ')'); await page.mouse.click(r.x, r.y); await sleep(300); }
+ check('a tap near the foot brings the seek bar up', await page.evaluate((cur) => getComputedStyle(eval(cur)().querySelector('.mp-seek')).opacity === '1', '(' + CUR + ')'));
const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')');
await page.mouse.move(bar.x + bar.w * 0.1, bar.y);
await page.mouse.down();
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/fullscreen-notice-test.js b/tools/fullscreen-notice-test.js
index cf15f8c..d3b2785 100644
--- a/tools/fullscreen-notice-test.js
+++ b/tools/fullscreen-notice-test.js
@@ -68,6 +68,8 @@ async function run(lockOk) {
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);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
diff --git a/tools/gift-from-test.js b/tools/gift-from-test.js
new file mode 100644
index 0000000..a0ffaf8
--- /dev/null
+++ b/tools/gift-from-test.js
@@ -0,0 +1,71 @@
+// A gift link can name its sender: #f=<base32 pubkey|created_at|sig>, a
+// nostr signature over the gift's code. The claim screen shows who it's
+// from (and that claiming follows them); a link whose signature doesn't
+// match its code — passed on as someone else's gift — shows no sender.
+// Run: bun run build && bun tools/gift-from-test.js
+import puppeteer from './puppeteer.js';
+import { base32nopad } from '@scure/base';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+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 server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const giftCode = () => {
+ const b = new Uint8Array(42);
+ b[0] = 0x11; b[1] = 3; // ark gift, mutinynet
+ new DataView(b.buffer).setBigUint64(2, 1000n, true);
+ b.set(crypto.getRandomValues(new Uint8Array(32)), 10);
+ return base32nopad.encode(b);
+};
+const sk = generateSecretKey();
+const fromFrag = (code) => {
+ const ev = finalizeEvent({ kind: 27121, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: [['x', bytesToHex(sha256(new TextEncoder().encode(code.toUpperCase())))]] }, sk);
+ const b = new Uint8Array(100);
+ b.set(hexToBytes(ev.pubkey), 0);
+ new DataView(b.buffer).setUint32(32, ev.created_at);
+ b.set(hexToBytes(ev.sig), 36);
+ return base32nopad.encode(b);
+};
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const text = (page) => page.evaluate(() => document.body.innerText);
+const waitText = async (page, re, ms = 30000) => {
+ for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); }
+ return false;
+};
+async function open(path) {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); });
+ await page.goto(server.url.href + path);
+ await waitText(page, /aren.t visible/i);
+ await sleep(500);
+ return page;
+}
+try {
+ const code = giftCode();
+ let page = await open(`ag/${code}#m=hi&f=${fromFrag(code)}`);
+ let tx = await text(page);
+ check('a signed link names its sender', /\bFrom\b/.test(tx) && /Claiming it follows them/.test(tx), tx.slice(0, 300).replace(/\n/g, ' | '));
+ check('the note still shows', /\bhi\b/.test(tx));
+ await page.close();
+ // an upper-case key, as a scanner may hand it over
+ page = await open(`ag/${code}#F=${fromFrag(code)}`);
+ { const tx = await text(page); check("an upper-cased link still names its sender", /Claiming it follows them/.test(tx), tx.slice(0, 200).replace(/\n/g, " | ")); }
+ await page.close();
+ // the same signature on a different gift: no sender
+ page = await open(`ag/${giftCode()}#f=${fromFrag(code)}`);
+ check('a signature over another code names nobody', !/Claiming it follows them/.test(await text(page)));
+ await page.close();
+} finally {
+ await browser.close();
+ server.stop();
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);
diff --git a/tools/gift-profile-setup-test.js b/tools/gift-profile-setup-test.js
new file mode 100644
index 0000000..9304d1a
--- /dev/null
+++ b/tools/gift-profile-setup-test.js
@@ -0,0 +1,96 @@
+// After a gift is opened into a fresh wallet and its seed written down, the
+// first-run wizard asks for a username, then a picture and a cover — the gift's
+// recipient should arrive as someone people can pay by name. "Not now" lands in
+// the account the gift went to.
+// Drives an ark gift on mutinynet whose bearer key holds nothing (a random
+// secret: the staging ASP sees no coin, so the sweep finds it taken), then
+// "Create wallet anyway" → the seed → on to the wizard.
+// Run: bun run build && bun tools/gift-profile-setup-test.js [screenshot.png]
+import puppeteer from './puppeteer.js';
+import { base32nopad } from '@scure/base';
+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 server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const giftCode = () => {
+ const b = new Uint8Array(42);
+ b[0] = 0x11; b[1] = 3; // ark gift, mutinynet
+ new DataView(b.buffer).setBigUint64(2, 1000n, true);
+ b.set(crypto.getRandomValues(new Uint8Array(32)), 10);
+ return base32nopad.encode(b);
+};
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const text = (page) => page.evaluate(() => document.body.innerText);
+const click = (page, x) => page.evaluate((x) => {
+ const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x));
+ e?.click(); return !!e;
+}, x);
+const waitText = async (page, re, ms = 30000) => {
+ for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); }
+ return false;
+};
+async function toSeed(page, note) {
+ await page.goto(server.url.href);
+ await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); });
+ await page.goto(server.url.href + 'ag/' + giftCode() + (note ? '#m=' + encodeURIComponent(note) : ''));
+ check('the gift opens: the server sees no coin behind it', await waitText(page, /aren.t visible/i));
+ if (note) {
+ const q = await page.evaluate(() => document.querySelector('.gift-note')?.textContent || null);
+ check('the sender\'s note shows on the gift', q === note, String(q));
+ check('...and leaves the address bar with the code', await page.evaluate(() => location.pathname === '/' && !location.hash));
+ }
+ await click(page, 'claim');
+ check('...so the claim finds it taken', await waitText(page, /create a wallet anyway/i));
+ check('...create a wallet anyway shows the seed', await click(page, 'create a wallet anyway') && await waitText(page, /recovery phrase/i, 10000));
+}
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await toSeed(page, 'Happy birthday! 🎂 <b>not bold</b>');
+ await click(page, 'skip verification');
+ check('the seed done: the wizard asks for a username', await waitText(page, /now pick a username/i, 10000), (await text(page)).slice(0, 200));
+ check('...with the claim form (or its wait) and Not now', await page.evaluate(() => !!document.querySelector('.onb')) && /not now/i.test(await text(page)));
+ if (process.argv[2]) await page.screenshot({ path: process.argv[2] });
+ // the look step follows the name, as in any first run
+ await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
+ await page.reload();
+ check('the next step is the picture and cover', !!await page.waitForSelector('.onb-cover', { timeout: 15000 }).catch(() => null));
+ // a second gift, verified this time, then Not now
+ const p2 = await browser.newPage();
+ p2.on('pageerror', (e) => errors.push(String(e)));
+ await p2.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await toSeed(p2);
+ check('Verify backup opens the word check', await click(p2, 'verify') && await waitText(p2, /word #?\d/i, 5000));
+ await sleep(500);
+ // the verify page doesn't show the words: read them from the account
+ const filled = await p2.evaluate(() => {
+ const accs = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const list = Array.isArray(accs) ? accs : accs.accounts || accs.list || [];
+ const m = list.map((a) => a.mnemonic).filter(Boolean).pop(); if (!m) return false;
+ const w = m.split(' ');
+ for (const lab of document.querySelectorAll('label.field')) {
+ const n = Number(lab.querySelector('.lab').textContent.match(/\d+/)[0]);
+ const inp = lab.querySelector('input');
+ const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
+ set.call(inp, w[n - 1]); inp.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ return true;
+ });
+ if (filled) {
+ await click(p2, 'open wallet');
+ check('a verified seed leads to the username step too', await waitText(p2, /now pick a username/i, 10000));
+ await click(p2, 'not now');
+ let gone = false;
+ for (let i = 0; i < 20 && !gone; i++) { await sleep(250); gone = !(await p2.evaluate(() => !!document.querySelector('.onb'))); }
+ check('Not now: into the wallet, the wizard gone', gone, (await text(p2)).slice(0, 200));
+ } else console.log(' (accounts not in localStorage — verify path not driven)');
+ check('no page errors', errors.length === 0, errors.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a gift\'s new wallet is offered a name, a picture and a cover' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
new file mode 100644
index 0000000..c3fc8e3
--- /dev/null
+++ b/tools/go-live-test.js
@@ -0,0 +1,289 @@
+// Going live: a wallet with a coinos name opens Go live from the composer,
+// sees its camera, starts — the recording goes over the WebSocket to the live
+// server (coinosv3-server live/, run here beside the built app), the stream is
+// announced as its kind 30311 pointing at the HLS — and someone else opening
+// the stream's link watches it. Ending says so in a last 30311 carrying the
+// recording, and the recording is complete.
+// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/;
+// LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5304;
+const dir = mkdtempSync(tmpdir() + '/go-live-');
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+// the registrar, as the live server asks it: the name is whoever is sending's
+let owner = null, nameCreated = null;
+const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner, created: nameCreated } : { taken: false }) });
+process.env.LIVE_DATA = dir;
+process.env.NAMES_URL = names.url.origin;
+process.env.LIVE_BASE = `http://localhost:${PORT}/live`;
+const LIVE_PATH = process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js';
+let live = await import(LIVE_PATH);
+// each socket stays with the server (module) that took it, so a "restart"
+// (a fresh import) leaves the old connections to the old one
+const sockMod = new WeakMap();
+const websocket = {
+ open: (ws) => { sockMod.set(ws, live); live.websocket.open?.(ws); },
+ message: (ws, m) => (sockMod.get(ws) || live).websocket.message?.(ws, m),
+ close: (ws, c, r) => (sockMod.get(ws) || live).websocket.close?.(ws, c, r),
+};
+const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) {
+ if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {}
+ if (path === '/live/allowed') try { owner = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))).pubkey; } catch {}
+ return live.handle(req, srv);
+ }
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
+ '--auto-select-desktop-capture-source=Entire screen',
+ // LATENCY=1: both pages keep full speed (a background tab's timers crawl)
+ ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] });
+// LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and
+// white blocks — and the viewer reads it off the frame it shows: how far
+// behind the broadcaster a watcher sees, end to end
+const clockCam = () => {
+ const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (c) => {
+ if (!c || !c.video) return real(c);
+ const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720;
+ const x = cv.getContext('2d');
+ setInterval(() => {
+ const v = Date.now() % 16777216;
+ x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720);
+ for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); }
+ }, 1000 / 30);
+ const s = cv.captureStream(30);
+ if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); }
+ return s;
+ };
+};
+const readClock = () => {
+ const v = document.querySelector('video.live-video');
+ if (!v || !v.videoWidth) return null;
+ const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight;
+ const x = cv.getContext('2d'); x.drawImage(v, 0, 0);
+ const k = v.videoWidth / 1280;
+ let n = 0;
+ for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; }
+ return ((Date.now() % 16777216) - n + 16777216) % 16777216;
+};
+
+const relayStub = (fx) => {
+ // what the relays held before a reload (a test can carry events across one)
+ try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {}
+ window.__published = [];
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ if (/\/live\/ingest/.test(String(a[0]))) { (window.__ingest = window.__ingest || []).push(ws); return ws; } // the live server itself
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const lives = (page) => page.evaluate(() => window.__published.filter((e) => e.kind === 30311));
+
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, []);
+ if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam);
+ // the registrar on the open internet is not asked: the name below is what the wallet holds
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue()));
+ page.on('dialog', (d) => d.accept());
+ const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate((k) => {
+ localStorage.setItem(k + ':names', JSON.stringify({ byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, base);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('[getting ready]');
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000));
+ // a name claimed an hour ago can't stream yet: the screen says when it can
+ nameCreated = Date.now() - 3600_000;
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ check('a name claimed an hour ago is told it can go live a day after, Start off', await waitFor(() => !!document.querySelector('.go-live-refused') && /a day after you claimed it/.test(document.querySelector('.go-live-refused').textContent), 10000)
+ && await page.evaluate(() => document.querySelector('.go-live-start').disabled));
+ await page.evaluate(() => document.querySelector('.live-page .chat-back, .live-page .iconbtn')?.click());
+ nameCreated = Date.now() - 2 * 86400_000;
+ await waitFor(() => !document.querySelector('.go-live-start'), 5000);
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000));
+ check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror')));
+ await page.type('.go-live-title', 'Testing the live button');
+
+ console.log('\n[live]');
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ check('starting opens the stream\'s page, with the clock and End', await waitFor(() => !!document.querySelector('.live-page .bc-bar .bc-end'), 40000));
+ const ann = (await lives(page)).at(-1);
+ const tag = (e, k) => (e.tags.find((x) => x[0] === k) || [])[1];
+ check('the stream is announced: a 30311 of yours, live, pointing at its HLS', !!ann && tag(ann, 'status') === 'live' && /\/live\/[0-9a-f]{16}\/live\.m3u8$/.test(tag(ann, 'streaming')) && tag(ann, 'title') === 'Testing the live button'
+ && ann.tags.some((x) => x[0] === 'p' && x[1] === ann.pubkey && x[3] === 'Host'), JSON.stringify(ann && ann.tags));
+ check('...by the key the server let in', ann && ann.pubkey === owner);
+ check('the page shows you, not the stream seconds behind', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); return !!v && v.videoWidth > 0 && !document.querySelector('.live-page video.live-video:not(.bc-preview)'); }, 5000));
+ const hls = tag(ann, 'streaming');
+ const m3u8 = await (await fetch(hls)).text();
+ check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[camera ⇄ screen while live]');
+ const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ await page.evaluate(() => document.querySelector('.bc-switch').click());
+ check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending());
+ await page.evaluate(() => document.querySelector('.bc-switch').click());
+ check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending());
+ await sleep(4000);
+ const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length;
+ check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window');
+
+ console.log('\n[someone watching]');
+ const viewer = await browser.newPage();
+ if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); });
+ await viewer.setViewport({ width: 390, height: 844 });
+ await viewer.evaluateOnNewDocument(relayStub, [ann]);
+ const v = helpers(viewer);
+ await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' });
+ check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000));
+ const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000);
+ if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30))));
+ check('...and it plays', playsOk || await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
+ JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; })));
+ // LIVE_CONFIG with the SFU's key ({sfuAppId, sfuSecret}): the app's viewer
+ // watches over WebRTC through Cloudflare; without it, HLS
+ const overRtc = await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return !!x && x.srcObject instanceof MediaStream; });
+ check(process.env.LIVE_CONFIG ? '...over WebRTC (Cloudflare\'s SFU)' : '...over HLS (no SFU key here)', overRtc === !!process.env.LIVE_CONFIG);
+ if (process.env.LATENCY) {
+ await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); });
+ const lags = [];
+ for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); }
+ if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; })));
+ lags.sort((a, b) => a - b);
+ console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`);
+ }
+ const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json();
+ check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st));
+ await viewer.close();
+
+ console.log('\n[the server restarts mid-stream]');
+ // a fresh server, and the old one's shutdown: its streams end
+ live = await import(LIVE_PATH + '?restart=1');
+ const oldId = tag(ann, 'd');
+ const sj = `${dir}/${oldId}/stream.json`;
+ writeFileSync(sj, JSON.stringify({ ...JSON.parse(readFileSync(sj, 'utf8')), state: 'ended', ended: Date.now() }));
+ const before = (await lives(page)).length;
+ await page.evaluate(() => window.__ingest.forEach((w) => w.close()));
+ check('the app comes back as a new stream and announces it', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n)
+ .some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'live')), 40000, before));
+ const after = (await lives(page)).slice(before);
+ const again = after.find((e) => tag(e, 'status') === 'live');
+ check('...at its new address, not the dead one', again && tag(again, 'd') !== oldId && tag(again, 'streaming').includes(tag(again, 'd')), JSON.stringify(after.map((e) => [tag(e, 'd'), tag(e, 'status')])));
+ const gone = after.find((e) => tag(e, 'd') === oldId);
+ check('...the old one said to be over, with no recording', gone && tag(gone, 'status') === 'ended' && !gone.tags.some((x) => x[0] === 'recording'));
+ const pl = await (await fetch(tag(again, 'streaming'))).text();
+ check('...and the new one plays', (pl.match(/\.ts/g) || []).length >= 3, (pl.match(/\.ts/g) || []).length + ' segments');
+ check('the streamer\'s own page follows to it', await waitFor((a) => !!document.querySelector('.live-page .bc-bar'), 5000));
+
+ console.log('\n[the end]');
+ await page.evaluate(() => document.querySelector('.bc-end').click());
+ check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000));
+ check('...and lets go of the camera', await page.evaluate(() => !document.querySelector('video.bc-preview')));
+ check('a last 30311 says it ended, with the recording', await waitFor(() => window.__published.some((e) => e.kind === 30311 && e.tags.some((x) => x[0] === 'status' && x[1] === 'ended') && e.tags.some((x) => x[0] === 'recording')), 8000));
+ const fin = (await lives(page)).at(-1);
+ await sleep(1500);
+ const rec = await (await fetch(tag(fin, 'recording'))).text();
+ check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[a stream the app lost]');
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' ')));
+ if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.go-live-tool'), 5000);
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000);
+ await page.type('.go-live-title', 'Lost stream');
+ const n0 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0);
+ const lost = (await lives(page)).at(-1);
+ // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it
+ await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost);
+ await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' });
+ await sleep(64000);
+ check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000));
+ const n1 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-end-stale').click());
+ check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1));
+ const endedEv = (await lives(page)).at(-1);
+ check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags));
+ check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000));
+
+ console.log('\n[deleting your recording]');
+ check('the streamer has Delete recording on their ended stream', await waitFor(() => !!document.querySelector('.live-delete'), 25000));
+ const n2 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-delete').click());
+ check('...deleting it says so, and the button goes', await waitFor(() => /deleted this recording/.test(document.body.innerText) && !document.querySelector('.live-delete'), 8000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...the server has let it go (410)', (await fetch(tag(lost, 'streaming').replace('live.m3u8', 'index.m3u8'))).status === 410);
+ const redone = (await lives(page)).slice(n2).at(-1);
+ check('...and the stream is announced again, ended, with no recording', !!redone && tag(redone, 'd') === tag(lost, 'd') && tag(redone, 'status') === 'ended' && !redone.tags.some((x) => x[0] === 'recording'), JSON.stringify(redone && redone.tags));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true); names.stop(true);
+ await sleep(300);
+ if (!process.env.KEEP) rmSync(dir, { recursive: true, force: true }); else console.log('kept', dir);
+}
+console.log(ok ? '\n✅ going live: camera to stream to watcher' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js
new file mode 100644
index 0000000..0ab1c1e
--- /dev/null
+++ b/tools/grid-early-player-test.js
@@ -0,0 +1,138 @@
+// Opening a clip from the Videos grid: the clip hosts are preconnected,
+// and the player starts loading when the finger comes down on a tile —
+// the pager adopts that very element, so it's moving sooner after the tap.
+// A press that isn't followed by a tap (a scroll) lets its player go.
+// Run: bun tools/grid-early-player-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// that); on a link slower than the clip, more — to the 4 MB cap. With Data
+// Saver on, the pager is careful again: such a clip waits for a tap and
+// nothing loads ahead.
+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, statSync } 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; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+// one file each, a byte or two apart (trailing padding): one person's clips of
+// one size to the byte are taken for one clip uploaded twice
+const FILES = Object.fromEntries(NAMES.map((n, i) => { const f = new Uint8Array(SIZE + i); f.set(FILE); return [n, f]; }));
+
+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 });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ const F = FILES[m[1]], S = F.length;
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(S) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], S - 1) : S - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(F.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${S}` } : {}) } });
+ }
+ 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.replace('localhost', '127.0.0.1') + '/' + n + '.webm'; // another origin than the page's: one to preconnect to
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + (SIZE + i), 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.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 vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ 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: 'Prime 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());
+ await page.waitForSelector('.media-mode[data-mode="videos"]', { timeout: 20000 });
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the Videos grid shows the clips', await waitFor(() => document.querySelectorAll('.media-tile.video').length >= 3, null, 15000));
+ check('…their host is connected to ahead', await page.evaluate((o) => [...document.querySelectorAll('link[rel=preconnect]')].some((l) => l.href.startsWith(o)), server.url.origin.replace('localhost', '127.0.0.1')));
+ // a press with no tap after it (the start of a scroll): its player is let go
+ await page.evaluate(() => document.querySelectorAll('.media-tile.video')[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
+ await sleep(600);
+ const pressed = await page.evaluate(() => { const v = document.querySelector('body > video[data-early]'); return v && { src: v.getAttribute('src'), rs: v.readyState }; });
+ check('a press starts the clip loading before any tap: its player has its picture', pressed && /c1\.webm$/.test(pressed.src) && pressed.rs >= 2, JSON.stringify(pressed));
+ await sleep(2500);
+ check('…and let go when no tap follows', await page.evaluate(() => !document.querySelector('body > video[data-early]')));
+ // a press, then the tap: the pager plays the very player the press made
+ await page.evaluate(() => { const t = document.querySelectorAll('.media-tile.video')[0]; t.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); setTimeout(() => t.click(), 120); });
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('…on the player the press started, adopted', await waitFor(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && v.dataset.early === '1' && !v.paused && v.currentTime > 0; }, null, 8000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && { early: v.dataset.early, paused: v.paused, t: v.currentTime }; })));
+ check('…and only one player was ever made for it', await page.evaluate(() => document.querySelectorAll('.media-pager video.mp-media[data-early]').length) === 1);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a clip opened from the grid starts loading at the press' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/history-export-test.js b/tools/history-export-test.js
new file mode 100644
index 0000000..f7fff82
--- /dev/null
+++ b/tools/history-export-test.js
@@ -0,0 +1,81 @@
+// Settings → Wallet → Export CSV: every payment in Spending (seeded Ark
+// movements) as a line of a CSV file, oldest first, with what was paid, the
+// fee beside it and the change to the balance; a failed payment is left out.
+// Run: bun tools/history-export-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.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 ns = (url) => { let h = 5381; for (const c of url) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); };
+const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
+const movements = [
+ { id: 'm1', type: 'ln-receive', status: 'complete', amountSat: 5000, invoice: 'lnbcrt50u1test', ts: T0 },
+ { id: 'm2', type: 'ln-send', status: 'complete', amountSat: 1000, detail: 'fee 3 sat', paymentHash: 'ab'.repeat(32), invoice: 'lnbcrt10u1test', ts: T0 + 60_000 },
+ { id: 'm3', type: 'send', status: 'complete', amountSat: 700, to: 'tark1someone', vtxoId: 'cd'.repeat(32) + ':0', ts: T0 + 120_000 },
+ { id: 'm4', type: 'send', status: 'failed', amountSat: 999, detail: 'nope', ts: T0 + 180_000 },
+ { id: 'm5', type: 'refresh', status: 'complete', amountSat: 3297, feeSat: 300, manual: true, txid: 'ef'.repeat(32), ts: T0 + 240_000 },
+];
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.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)));
+// the file the page hands the browser to save
+await page.evaluateOnNewDocument(() => {
+ const make = URL.createObjectURL;
+ URL.createObjectURL = (b) => { b.text().then((x) => { window.__csv = x; window.__csvType = b.type; }); return make(b); };
+ const click = HTMLAnchorElement.prototype.click;
+ HTMLAnchorElement.prototype.click = function () { if (this.download) { window.__csvName = this.download; return; } return click.call(this); };
+});
+const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
+const waitText = async (x, ms = 20000) => { 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; };
+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');
+ // Spending's history, as the Ark manager stores it (moved into IndexedDB at the next boot)
+ const key = cacheKeyFor(mn + '\n') + ':ark:' + ns('http://localhost:3535');
+ await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify(v)), [key, { v: 1, _rev: 999, vtxos: [], actions: [], movements, scheduled: [] }]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(500);
+ check('Settings → Wallet has the export', await waitText('transaction history', 5000) && !!(await page.$('.history-export')));
+ await page.evaluate(() => document.querySelector('.history-export').click());
+ for (let i = 0; i < 20 && !(await page.evaluate(() => window.__csv)); i++) await sleep(100);
+ const { csv, name, type } = await page.evaluate(() => ({ csv: window.__csv, name: window.__csvName, type: window.__csvType }));
+ check('a .csv file is saved, named for today', /^coinos-history-\d{4}-\d{2}-\d{2}\.csv$/.test(name || '') && /text\/csv/.test(type || ''), name);
+ const rows = (csv || '').trim().split('\r\n').map((l) => l.split(','));
+ console.log((csv || '').trim().split('\r\n').map((l) => ' ' + l).join('\n'));
+ const head = rows[0] || [];
+ const col = (r, n) => r[head.indexOf(n)];
+ check('a header names the columns', head[0] === 'Date (UTC)' && head.includes('Net (sats)') && head.includes('Fee (sats)'));
+ const body = rows.slice(1);
+ check('...one line per payment, the failed one left out, oldest first', body.length === 4 && body.map((r) => col(r, 'Type')).join() === 'ln-receive,ln-send,send,refresh', body.map((r) => col(r, 'Type')).join());
+ check('...dated in UTC', col(body[0], 'Date (UTC)') === '2026-10-01 12:00:00');
+ check('a receive: + its amount, on Lightning, in Spending', col(body[0], 'Account') === 'Spending' && col(body[0], 'Rail') === 'lightning' && col(body[0], 'Net (sats)') === '5000');
+ check('a Lightning payment: what was paid, its fee, both off the balance', col(body[1], 'Amount (sats)') === '-1000' && col(body[1], 'Fee (sats)') === '3' && col(body[1], 'Net (sats)') === '-1003' && col(body[1], 'Transaction / payment hash') === 'ab'.repeat(32));
+ check('an Ark payment names who it went to', col(body[2], 'Net (sats)') === '-700' && col(body[2], 'To') === 'tark1someone' && col(body[2], 'Rail') === 'ark');
+ check('a renewal costs its fee alone', col(body[3], 'Amount (sats)') === '0' && col(body[3], 'Net (sats)') === '-300');
+ check('the lines add up to the balance they leave', body.reduce((s, r) => s + Number(col(r, 'Net (sats)')), 0) === 5000 - 1003 - 700 - 300);
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ history exports as CSV' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js
new file mode 100644
index 0000000..0a3feea
--- /dev/null
+++ b/tools/hls-upload-test.js
@@ -0,0 +1,92 @@
+// A long video posted from the app goes up as an HLS stream: made on the
+// device (two renditions, a file each, read in ranges), put on one Blossom
+// host with the playlists naming the files' addresses, and the master
+// playlist's address is what the post carries. Chrome plays it from there
+// and seeks in it. A short clip is no stream.
+// Run: bun tools/hls-upload-test.js
+import puppeteer from './puppeteer.js';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/hls-upload-');
+const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]);
+make(95, dir + '/long.mp4');
+make(20, dir + '/short.mp4');
+const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true });
+if (!built.success) throw new Error(built.logs.join('\n'));
+const js = new Map();
+for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text());
+// a Blossom host: PUT /upload keeps the body under its hash; GET /<hash>.<ext> serves it in ranges
+const blobs = new Map(), types = new Map();
+const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
+ const u = new URL(req.url);
+ if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } });
+ if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname));
+ if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 });
+ if (u.pathname === '/upload' && req.method === 'PUT') {
+ const body = new Uint8Array(await req.arrayBuffer());
+ const x = createHash('sha256').update(body).digest('hex');
+ blobs.set(x, body); types.set(x, req.headers.get('content-type'));
+ return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length });
+ }
+ const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname);
+ if (m && blobs.has(m[1])) {
+ const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' };
+ if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); }
+ return new Response(buf, { headers });
+ }
+ return new Response('<!doctype html><video muted></video>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const run = (name) => page.evaluate(async (name) => {
+ const vc = await import('/video-compress.js'), mu = await import('/media-upload.js');
+ const blob = await (await fetch('/' + name)).blob();
+ const file = new File([blob], name, { type: 'video/mp4' });
+ const t0 = performance.now();
+ const stream = await vc.streamVideo(file);
+ if (!stream) return { stream: null };
+ const made = Math.round(performance.now() - t0);
+ let progress = 0;
+ const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } });
+ return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds };
+ }, name);
+ const short = await run('short.mp4');
+ check('a 20 s clip is not made a stream', short.stream === null);
+ const long = await run('long.mp4');
+ check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms');
+ check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url);
+ check('...the upload reported its progress to the end', long.progress === 1, String(long.progress));
+ check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds);
+ const master = await (await fetch(long.url)).text();
+ const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : []));
+ check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' '));
+ check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master));
+ const media = await (await fetch(lists.at(-1))).text();
+ check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media));
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message };
+ v.currentTime = 70;
+ const t1 = performance.now();
+ while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, long.url);
+ check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first));
+ check('...and seeks in it', played.after >= 71, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ a long video goes up as an HLS stream' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/image-thumbnails-test.js b/tools/image-thumbnails-test.js
index ede7e11..6ece275 100644
--- a/tools/image-thumbnails-test.js
+++ b/tools/image-thumbnails-test.js
@@ -1,4 +1,4 @@
-// Real minified blob worker: both thumbnail sizes, animated originals,
+// Real minified blob worker: both thumbnail sizes (the tile in the picture's own shape), animated originals,
// bounded concurrency and graceful fallback, with no page-thread encoding.
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
@@ -30,25 +30,30 @@ try {
thumbnails(new Blob(['broken'],{type:'image/jpeg'}),{width:1100}), thumbnails(blob,{width:1100}),
]);
const media = await decode(big.media), tile = await decode(big.tile);
- const square = new OffscreenCanvas(360,360);square.getContext('2d').drawImage(tile,0,0);
- const pixel=[...square.getContext('2d').getImageData(0,180,1,1).data];
+ const whole = new OffscreenCanvas(tile.width,tile.height);whole.getContext('2d').drawImage(tile,0,0);
+ const pixel=[...whole.getContext('2d').getImageData(tile.width>>1,180,1,1).data], edge=[...whole.getContext('2d').getImageData(2,180,1,1).data];
+ // a panorama past 2:1 keeps its middle, 2:1
+ const pano=new OffscreenCanvas(4000,1000);pano.getContext('2d').fillStyle='green';pano.getContext('2d').fillRect(0,0,4000,1000);
+ const panoTile=await decode((await thumbnails(await encode.call(pano,{type:'image/png'}),{tileSize:360})).tile);
const smallCanvas=new OffscreenCanvas(32,20);smallCanvas.getContext('2d');
const smallBlob=await encode.call(smallCanvas,{type:'image/png'});
const small=await thumbnails(smallBlob,{width:1100,tileSize:360});
const smallTile=await decode(small.tile);
const gif=new Blob([Uint8Array.from(atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),c=>c.charCodeAt(0))],{type:'image/gif'});
const animated=await thumbnails(gif,{width:1100,tileSize:360});
- const out={mainCalls,maxPending,big:[big.width,big.height],media:[media.width,media.height],tile:[tile.width,tile.height],pixel,
+ const out={mainCalls,maxPending,big:[big.width,big.height],media:[media.width,media.height],tile:[tile.width,tile.height],pixel,edge,pano:[panoTile.width,panoTile.height],
tileOnly:!tiles.media&&!!tiles.tile,bad,recovered:!!recovered.media,smallOriginal:small.media===null,smallTile:[smallTile.width,smallTile.height],
animated: animated && [animated.width,animated.height,animated.media,animated.tile]};
media.close();tile.close();smallTile.close();return out;
});
console.log(result);
assert.equal(result.mainCalls,0);assert.equal(result.maxPending,1);
- assert.deepEqual(result.big,[3000,2000]);assert.deepEqual(result.media,[1100,733]);assert.deepEqual(result.tile,[360,360]);
- assert(result.pixel[2]>200&&result.pixel[0]<30,'tile crops the center');
+ assert.deepEqual(result.big,[3000,2000]);assert.deepEqual(result.media,[1100,733]);assert.deepEqual(result.tile,[540,360]);
+ // the grid's justified rows: a tile in the picture's own shape, 360 tall, uncut up to 2:1
+ assert(result.pixel[2]>200&&result.pixel[0]<30&&result.edge[0]>200,'tile keeps the whole picture');
+ assert.deepEqual(result.pano,[720,360]);
assert(result.tileOnly&&result.recovered&&result.smallOriginal);assert.equal(result.bad,null);
- assert.deepEqual(result.smallTile,[20,20]);assert.deepEqual(result.animated,[1,1,null,null]);
+ assert.deepEqual(result.smallTile,[32,20]);assert.deepEqual(result.animated,[1,1,null,null]);
const fallback=await browser.newPage();await fallback.goto(server.url.href);await fallback.waitForFunction(()=>window.thumbnails);
assert.equal(await fallback.evaluate(async()=>{window.Worker=class {constructor(){throw Error('blocked');}};return thumbnails(new Blob(['x']),{width:1100});}),null);
console.log('✓ thumbnails stay off the page thread; sizes, crop, originals, queue, bad-image recovery and worker fallback pass');
diff --git a/tools/kind0-overwrite-test.js b/tools/kind0-overwrite-test.js
new file mode 100644
index 0000000..e791560
--- /dev/null
+++ b/tools/kind0-overwrite-test.js
@@ -0,0 +1,141 @@
+// A profile (kind 0) must never be written over one we failed to find.
+//
+// 2026-10-30: a long-time nostr user signed in to coinos with his key for
+// the first time. His kind 0 lived on relays that keep history (ditto,
+// snort, oxtr) and nowhere our quick lookup asked, so the payment-address
+// publish merged over an empty base and went out as {lud16, nip05} alone:
+// name, picture and banner gone from every relay. These scenarios run the
+// messages feature against a fake relay network where only an archive holds
+// the real profile, and record every event the wallet publishes.
+// Run: bun tools/kind0-overwrite-test.js
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+
+// per-relay stores: REQ answered from the socket's own relay, EVENT stored there
+const relayStore = {};
+const published = []; // { url, ev }
+const reqs = []; // { url, filter }
+const norm = (u) => String(u).replace(/\/$/, '');
+class FakeSocket {
+ constructor(url) {
+ this.url = norm(url); this.readyState = 0;
+ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0);
+ }
+ reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); }
+ send(str) {
+ let m; try { m = JSON.parse(str); } catch { return; }
+ if (m[0] === 'EVENT') { (relayStore[this.url] ||= []).push(m[1]); published.push({ url: this.url, ev: m[1] }); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ for (const f of filters) reqs.push({ url: this.url, filter: f });
+ if (filters.some((f) => f.cache)) { this.reply(['EOSE', sub]); return; } // Primal's cache knows nothing here
+ const hit = (relayStore[this.url] || []).filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))));
+ hit.sort((a, b) => b.created_at - a.created_at);
+ for (const e of hit) this.reply(['EVENT', sub, e]);
+ this.reply(['EOSE', sub]);
+ }
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({}); }
+}
+globalThis.WebSocket = FakeSocket;
+globalThis.Image = class { set src(v) { this._src = v; } };
+globalThis.fetch = async () => { throw new Error('offline'); };
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const ARCHIVE = 'wss://relay.ditto.pub';
+const makeFeature = (sk, ui = {}) => {
+ const pk = getPublicKey(sk);
+ const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+ const walletSk = generateSecretKey();
+ const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostrRelays: () => [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ loadFeatureState: () => null, saveFeatureState: () => {},
+ _cacheKey: () => 'k0-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
+ };
+ const toasts = [];
+ const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui, render: () => {}, wallet, toast: (m) => toasts.push(m), brandHeader: () => null,
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
+ });
+ return { feature, pk, toasts };
+};
+const newestOn = (url, pk) => (relayStore[url] || []).filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const kind0s = () => published.filter((p) => p.ev.kind === 0);
+
+console.log('[a first sign-in: the real profile lives only on an archive relay]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const old = { name: 'Nik kusanagi', display_name: 'Nik', lud16: '[email protected]', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
+ relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
+ const { feature } = makeFeature(sk);
+ // the payment-address publish that wiped him (lud16/nip05 empty or at the old address → repoint)
+ await feature.addressRenamed(null, '[email protected]');
+ const sent = [...new Set(kind0s().map((p) => p.ev.id))];
+ check('one kind 0 went out', sent.length === 1, sent.length);
+ const c = sent.length ? JSON.parse(kind0s()[kind0s().length - 1].ev.content) : {};
+ check('it fills the empty nip05 and leaves the lightning address he had', c.nip05 === '[email protected]' && c.lud16 === '[email protected]', JSON.stringify(c));
+ check('...and carries the name, face and cover the archive held', c.name === 'Nik kusanagi' && /face/.test(c.picture) && /cover/.test(c.banner), JSON.stringify(c));
+ check('the archive\'s copy was asked for', reqs.some((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)));
+
+ // the onboarding wizard's suggestion must not rename him either
+ published.length = 0;
+ await feature.publishProfile({ name: 'satoshi1234', picture: 'https://punk/1.png' }, { fillOnly: ['name', 'picture'] });
+ check('a fill-only suggestion changes nothing on a profile that has a name', kind0s().length === 0, kind0s().length);
+}
+
+console.log('[a key with no profile anywhere: one deep look, then no waiting]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const { feature } = makeFeature(sk);
+ published.length = 0; reqs.length = 0;
+ const t0 = Date.now();
+ await feature.publishProfile({ name: 'newbie' });
+ const deep1 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
+ await feature.publishProfile({ about: 'hello' });
+ const deep2 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
+ const last = JSON.parse(kind0s().pop().ev.content);
+ check('the new key still gets its profile', last.name === 'newbie' && last.about === 'hello', JSON.stringify(last));
+ check('the archive was asked once, not again for the second publish', deep1 === 1 && deep2 === 1, `${deep1} ${deep2} in ${Date.now() - t0} ms`);
+}
+
+console.log('[already hit: the editor finds the older copy and fills what is empty]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const old = { name: 'Nik kusanagi', display_name: 'Nik', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
+ relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
+ // what the wipe left: the address and a picture he re-uploaded by hand
+ const wiped = { lud16: '[email protected]', nip05: '[email protected]', picture: 'https://image.nostr.build/new.png' };
+ for (const u of ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://purplepag.es', ARCHIVE]) (relayStore[u] ||= []).push(finalizeEvent({ kind: 0, created_at: 1793399187, tags: [], content: JSON.stringify(wiped) }, sk));
+ const ui = { profEdit: { uname: 'nicstazzema', name: '', about: '', picture: wiped.picture, banner: '' } };
+ const { feature, toasts } = makeFeature(sk, ui);
+ const versions = await feature.profileHistory();
+ check('both copies found', versions.length === 2, versions.map((e) => e.created_at).join(' '));
+ const older = versions.find((e) => /Nik/.test(e.content));
+ const n = feature.fillProfileFrom(older);
+ check('name and banner filled, the picture he chose kept', n === 2 && ui.profEdit.name === 'Nik' && ui.profEdit.banner === old.banner && ui.profEdit.picture === wiped.picture, JSON.stringify(ui.profEdit));
+ check('says what it filled', toasts.some((m) => /Filled 2 fields/.test(m)), toasts.join(' | '));
+ published.length = 0;
+ await feature.saveProfileDraft(ui.profEdit);
+ const saved = JSON.parse(kind0s().pop().ev.content);
+ check('Save publishes the name back with the address intact', saved.name === 'Nik' && saved.display_name === 'Nik' && saved.lud16 === wiped.lud16 && /new\.png/.test(saved.picture) && /cover/.test(saved.banner), JSON.stringify(saved));
+ check('newer than the wiped copy everywhere', newestOn('wss://relay.coinos.io', pk).created_at > 1793399187);
+}
+
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ profiles are never written over unseen');
+process.exit(fails ? 1 : 0);
diff --git a/tools/live-chat-actions-test.js b/tools/live-chat-actions-test.js
new file mode 100644
index 0000000..4e4d4ff
--- /dev/null
+++ b/tools/live-chat-actions-test.js
@@ -0,0 +1,74 @@
+// Real live-chat controls with local relays and a fake payment handler.
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import {messagesFeature} from './src/features/messages.js';
+import {getPublicKey} from 'nostr-tools/pure';
+import {liveReactions, zapAddrOf} from './src/live.js';
+${dom}
+const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64);
+const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]};
+const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null};
+const ui = {}, calls = [], toasts = [];
+window.__published=[]; window.__publishOK=true;
+let feature;
+const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}};
+function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));}
+feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false,
+ hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}});
+feature.testStart(w,ev); render();
+window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)};
+`;
+const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){
+ b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'}));
+ b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()}));
+ b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")}));
+ b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text())
+ .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;')
+ .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};')
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') }));
+}}]});
+assert(built.success,built.logs.join('\n'));
+const html = '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'+await Bun.file('src/style.css').text()+'</style><div id="app"></div><script type="module">'+await built.outputs[0].text()+'</script>';
+const server=Bun.serve({port:0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})});
+const browser=await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']});
+const page=await browser.newPage(), errors=[];
+page.on('pageerror',e=>errors.push(e.message));
+await page.setRequestInterception(true);
+page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort());
+const pause=ms=>new Promise(r=>setTimeout(r,ms));
+try {
+ await page.setViewport({width:390,height:844});
+ await page.goto(server.url.href);
+ await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); });
+ await page.click('.chat-zap');
+ assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream');
+ await page.evaluate(()=>{test.settle();test.self();});
+ assert(!(await page.$('.chat-zap')),'no self-zap button');
+ await page.evaluate(()=>test.other());
+ await page.click('.chat-bubble');
+ await page.waitForSelector('.msg-sheet-emojis');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7));
+ assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311'));
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction');
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id');
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ await page.evaluate(()=>window.__publishOK=false);
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000});
+ assert(await page.evaluate(()=>test.toasts.length>0));
+ const box = await (await page.$('.chat-zap')).boundingBox();
+ await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up();
+ assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings');
+ assert.deepEqual(errors,[]);
+ console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback');
+} finally {await browser.close();server.stop(true);}
diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js
new file mode 100644
index 0000000..b142528
--- /dev/null
+++ b/tools/live-reactions-test.js
@@ -0,0 +1,13 @@
+import assert from 'node:assert/strict';
+import { liveReactions } from '../src/live.js';
+const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]};
+const two = {...one,id:'two',created_at:2,content:'👍'};
+const other = {...one,id:'other',pubkey:'bob'};
+const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]};
+assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍');
+assert.equal(liveReactions([one, one, other]).get('comment').size,2);
+assert.equal(liveReactions([del, two, one, other]).get('comment').size,1);
+assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1);
+assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two');
+assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz');
+console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation');
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
new file mode 100644
index 0000000..2be6051
--- /dev/null
+++ b/tools/live-stream-test.js
@@ -0,0 +1,310 @@
+// Live streams (NIP-53): a feed shows its people's streams in a row above
+// the posts (a stream gone quiet for an hour is over), a tap opens the
+// stream's page — the player playing the HLS stream, the chat and zaps as
+// they come, a chat line sent as a kind 1311 — and the address bar names it
+// (/naddr1…). A post linking the stream shows it as a card, the host's
+// profile says they're on, Back stops the player, the browser without HLS
+// of its own plays it through hls.js, and a zap carries the stream's `a`.
+// Run: bun tools/live-stream-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { buildZapRequest } from '../src/lnurl.js';
+import { setZapAddr } from '../src/live.js';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const PORT = 5302;
+const now = Math.floor(Date.now() / 1000);
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const HOST = key(), SERVICE = key(), FAN = key(), STALE = key();
+
+// a stream: 30 s of HLS, its playlist without an end (as a live one is)
+const dir = mkdtempSync(tmpdir() + '/live-stream-');
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']);
+writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', ''));
+const STREAM = `http://localhost:${PORT}/live/index.m3u8`;
+
+// zap.stream's shape: the service signs, the streamer is the Host
+const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [
+ ['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'],
+ ['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)],
+ ['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'],
+] }, SERVICE.sk);
+const A = `30311:${SERVICE.pk}:stream-1`;
+const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' });
+// still says live, but nobody has refreshed it in two hours
+const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [
+ ['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'],
+] }, STALE.sk);
+const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk);
+const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
+const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
+const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
+// yesterday's stream, over, its recording kept
+const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '', tags: [
+ ['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+// one kept on a video site: its "recording" is a web page
+const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [
+ ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'],
+ ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk);
+// who's watching (NIP-53 presence): the fan, just now; someone two hours ago is gone
+const here = finalizeEvent({ kind: 10312, created_at: now - 60, content: '', tags: [['a', A, '', 'root']] }, FAN.sk);
+const longGone = finalizeEvent({ kind: 10312, created_at: now - 7200, content: '', tags: [['a', A, '', 'root']] }, STALE.sk);
+const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk);
+const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk);
+// and months of older ones, past the first ask's month: the grid pages back through them
+const OLD = Array.from({ length: 130 }, (_, i) => finalizeEvent({ kind: 30311, created_at: now - 31 * 86400 - i * 3600, content: '', tags: [
+ ['d', 'old-' + i], ['title', 'Old stream ' + i], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['ends', String(now - 31 * 86400 - i * 3600)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk));
+const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap, here, longGone, ...OLD];
+const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
+
+// the site as built (the split build: hls.js arrives as its own chunk)
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: PORT, fetch: async (req, srv) => {
+ if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return;
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+}, websocket: { message(ws, data) {
+ const m = JSON.parse(String(data));
+ if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, '']));
+} } });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+
+// the relays: they hold the fixtures, answer by the filter, and keep what's published
+const relayStub = (fx) => {
+ window.__published = []; window.__reqs = [];
+ const subs = [];
+ // a new event, to every open subscription it matches (a relay's live feed)
+ window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ window.__reqs.push([a[0], ...m.slice(2)]);
+ subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, until: undefined, limit: undefined }))]);
+ // each filter its newest `limit`, as a relay answers
+ const hits = [...new Set(m.slice(2).flatMap((f) => [...fx, ...window.__published].filter((e) => match(f, e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || Infinity)))];
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1] && !window.__published.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); }
+ if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 1);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+
+const newPage = async (noHls = false) => {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, FIXTURES);
+ // Firefox: no HLS of its own
+ if (noHls) await page.evaluateOnNewDocument(() => {
+ const real = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); };
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ page.errs = errs;
+ return page;
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; };
+
+try {
+ console.log('[a zap on a stream names it]');
+ setZapAddr(live.id, A);
+ const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e });
+ check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id));
+
+ const page = await newPage();
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ 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: 'Stream Host', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, HOST.pk, [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('\n[the feed]');
+ check('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => {
+ const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode);
+ return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot');
+ }, 15000));
+ check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip'))));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
+ check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 15000));
+ const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid:not(.live-past) .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
+ check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
+ check('past streams follow, the recording kept, its end time shown', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText) && /RECORDING/.test(t.innerText)), 8000)
+ && await page.evaluate(() => /Past streams/.test(document.body.innerText)));
+ console.log('\n[past streams scroll on]');
+ const pastCount = () => page.evaluate(() => document.querySelectorAll('.live-past .live-tile').length);
+ const first = await pastCount();
+ check('a page of them at first, not all', first > 0 && first <= 30, first + ' tiles');
+ let last = first;
+ for (let i = 0; i < 120 && !(await page.evaluate(() => document.body.innerText.includes('Old stream 129'))); i++) { // (this relay never says EOSE: each page waits out its 5 s)
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(250);
+ last = await pastCount();
+ }
+ check('scrolling down brings older ones, from the relays, back to the oldest', await page.evaluate(() => document.body.innerText.includes('Old stream 129')), last + ' tiles');
+ check('...asked for page by page (until the oldest so far)', await page.evaluate(() => window.__reqs.filter((r) => r.slice(1).some((f) => f.kinds && f.kinds.includes(30311) && f.until)).length >= 2));
+ check('...all of them there, once each', last === 132 && await page.evaluate(() => new Set([...document.querySelectorAll('.live-past .live-tile')].map((t) => t.dataset.key)).size === 132), last + ' tiles');
+ await page.evaluate(() => window.scrollTo(0, 0));
+ check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
+ check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
+ await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
+ check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
+ const lay = await page.evaluate(() => {
+ const r = (q) => document.querySelector(q)?.getBoundingClientRect();
+ const p = r('.live-page .live-player'), c = r('.live-page .live-chat');
+ return { w: document.body.clientWidth, player: p && [p.left, p.width], chat: c && [c.left, c.width] };
+ });
+ check('on a phone the picture runs edge to edge and the chat is as wide', lay.player && lay.player[0] === 0 && lay.player[1] === lay.w && lay.chat && lay.chat[0] === 0 && lay.chat[1] === lay.w, JSON.stringify(lay));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
+ await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 5000);
+
+ console.log('\n[the stream\'s page]');
+ await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .live-tile').click());
+ check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
+ check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
+ // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player
+ check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
+ check('the chat shows what was said', await waitText('hello from the chat', 8000));
+ const who = () => page.evaluate(() => { const x = document.querySelector('.live-page .live-watching'); return x && { text: x.textContent, title: x.getAttribute('title') || '' }; });
+ check('"watching" names who, on hover: the fan and this viewer, not someone long gone', await waitFor(() => (document.querySelector('.live-page .live-watching')?.getAttribute('title') || '').split(', ').length === 2, 8000), JSON.stringify(await who()));
+ check('...this viewer said they\'re here (a 10312 for the stream)', await page.evaluate((a) => window.__published.some((e) => e.kind === 10312 && e.tags.some((t) => t[0] === 'a' && t[1] === a)), A));
+ await page.evaluate(() => document.querySelector('.live-page .live-watching').click());
+ check('...a tap shows their faces', await waitFor(() => document.querySelectorAll('.live-page .live-who .live-who-p').length === 2, 3000));
+ check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
+ const rowSel = '.live-message[data-key="' + chat.id + '"]';
+ check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft'));
+ check('...drawn plain on a phone, no bubble walls', await page.evaluate((q) => { const b = document.querySelector(q); return b && getComputedStyle(b).borderStyle === 'none' && getComputedStyle(b).display === 'block'; }, rowSel + ' .chat-bubble'));
+ check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id));
+ check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id));
+ await page.click(rowSel + ' .chat-bubble');
+ check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000));
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click());
+ check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id));
+ const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id);
+ check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311'));
+ check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ await page.click(rowSel + ' .chat-react.on');
+ check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id));
+ check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk);
+ await page.evaluate(e => window.__push(e), remoteReaction);
+ check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id));
+ // The existing zap button must select this fan's npub, not the host.
+ const zapButton = await page.$(rowSel + ' .chat-zap');
+ check('the comment has the same one-tap zap control as a DM', !!zapButton);
+ await page.click(rowSel + ' .chat-bubble');
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click());
+ check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000));
+ await page.focus('.live-page #msg-draft');
+ await page.keyboard.type('first time here');
+ await page.keyboard.press('Enter');
+ check('a line typed in the chat shows at once', await waitText('first time here', 3000));
+ const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
+ check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
+ check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk));
+ check('the player keeps playing through the renders', await waitFor(playing, 3000));
+ if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); }
+
+ console.log('\n[ways out]');
+ await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click());
+ check('on the stream, the host\'s ringed face opens their profile (not the same stream again)', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-banner'), 8000));
+ await page.goBack();
+ await waitFor(() => !!document.querySelector('.live-page'), 5000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('Feed in the nav leaves the stream on the first tap', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000));
+ await page.goBack();
+ await waitFor(() => !!document.querySelector('.live-page'), 5000);
+
+ console.log('\n[leaving]');
+ await page.goBack();
+ check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
+ check('...and takes back this viewer\'s presence (an empty 10312)', await waitFor(() => window.__published.some((e) => e.kind === 10312 && !e.tags.some((t) => t[0] === 'a')), 3000));
+ check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000);
+ await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
+ check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
+ await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
+ check('the host\'s profile has a Streams tab', await waitFor(() => !!document.querySelector('.prof-tabs [data-tab="streams"]'), 10000) && !(await page.$('.live-past')));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="streams"]').click());
+ check('...listing their past streams', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click());
+ check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000)
+ && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video')));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page'), 5000);
+ check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
+ check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click());
+ check('a past stream opens on its recording, playing', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000)
+ && await page.evaluate(() => !document.body.innerText.includes('This stream has ended')));
+ check('no page errors', !page.errs.length, page.errs.join(' | '));
+ await page.close();
+
+ console.log('\n[a browser without HLS of its own, opening a link]');
+ const ff = await newPage(true);
+ const f = helpers(ff);
+ await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' });
+ check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000));
+ check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; })));
+ check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
+ check('a visitor reads the chat', await f.waitText('hello from the chat', 8000));
+ await ff.evaluate((e) => window.__push(e), ended);
+ check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video')));
+ check('no page errors', !ff.errs.length, ff.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ live streams: found, watched, chatted in' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js
new file mode 100644
index 0000000..18a1943
--- /dev/null
+++ b/tools/ln-pay-double-wake.test.js
@@ -0,0 +1,74 @@
+// Two wakes of the phone's worker, seconds apart, each over its own copy of
+// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The
+// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent'
+// to this very payment — and that once marked a PAID send 'revoking'. Now the
+// send follows the payment instead, and a send in 'revoking' asks the ASP
+// before revoking: paid → settled; still in flight → back to waiting.
+// Run: bun test tools/ln-pay-double-wake.test.js
+import { test, expect, mock } from 'bun:test';
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import * as realLn from '../src/ark/lightning.js';
+import * as realProto from '../src/ark/proto.js';
+import * as realSend from '../src/ark/send.js';
+
+const PRE = 'ab'.repeat(32);
+const HASH = hex.encode(sha256(hex.decode(PRE)));
+const asp = { initiate: null, check: null, revoked: 0, checks: 0 };
+mock.module('../src/ark/lightning.js', () => ({
+ ...realLn,
+ initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; },
+ checkLightningPayment: async () => { asp.checks++; if (asp.check instanceof Error) throw asp.check; return asp.check; },
+ requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); },
+}));
+mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) }));
+mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {},
+ buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) }));
+const { ArkManager } = await import('../src/ark/manager.js');
+const { GrpcError } = realProto;
+
+function mgrWith(step) {
+ const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' };
+ const m = Object.create(ArkManager.prototype);
+ const own = {
+ arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 },
+ state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] },
+ _save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(),
+ _key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }),
+ };
+ for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true });
+ return { m, action };
+}
+
+test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => {
+ Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 });
+ const { m, action } = mgrWith('cosigned');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('done');
+ expect(action.preimage).toBe(PRE);
+ expect(asp.revoked).toBe(0);
+ expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1);
+});
+
+test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('done');
+ expect(asp.revoked).toBe(0);
+});
+
+test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('initiated');
+ expect(asp.revoked).toBe(0);
+});
+
+test("a send the ASP never saw (not found) is still revoked", async () => {
+ Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action).catch(() => {});
+ expect(asp.revoked).toBe(1);
+});
diff --git a/tools/ln-receive-twin-test.js b/tools/ln-receive-twin-test.js
index 20d4f48..167a26b 100644
--- a/tools/ln-receive-twin-test.js
+++ b/tools/ln-receive-twin-test.js
@@ -35,5 +35,19 @@ f = lnFoldedIds([rcv('A', 1000, T), { ...rcv('B', 1000, T + 2000), vtxoId: 'v-A'
check('...or the earliest when none is tagged', f.size === 1 && f.has('B'));
f = lnFoldedIds([rcv('A', 1000, T), rcv('B', 1000, T + 2000)]);
check('two coins of the same amount stay two rows', f.size === 0);
+// a Lightning payment the closed app's worker made: its own ln-send row
+// (merged from the mirror) plus the app's "while closed" note of the same
+// preimage — one row, the one that names its inputs
+const snd = (id, ts, extra = {}) => ({ id, type: 'ln-send', status: 'complete', amountSat: 1, ts, ...extra });
+f = lnFoldedIds([snd('W', T, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] }), snd('N', T + 60_000, { preimage: 'pi1', detail: 'while closed · fee 0 sat' })]);
+check('a closed-app payment told twice shows once, the row with inputs', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('N', T, { preimage: 'pi1' }), snd('W', T + 5000, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] })]);
+check('...whichever order they were written', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('W', T, { paymentHash: 'h1', preimage: 'pi1', inputIds: ['v1'] }), snd('N', T + 1000, { paymentHash: 'h1' })]);
+check('...matched by payment hash when the note has no preimage', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('A', T, { preimage: 'pi1', inputIds: ['v1'] }), snd('B', T + 1000, { preimage: 'pi2', inputIds: ['v2'] })]);
+check('two payments stay two rows', f.size === 0);
+f = lnFoldedIds([snd('A', T, { preimage: 'pi1' }), snd('B', T + 1000, { preimage: 'pi1' })]);
+check('two notes with no inputs keep the earliest', f.size === 1 && f.has('B'));
console.log(ok ? '\n✅ a claimed LN receive is told once' : '\n❌ failed');
process.exit(ok ? 0 : 1);
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/media-grid-dups-test.js b/tools/media-grid-dups-test.js
new file mode 100644
index 0000000..e7b67da
--- /dev/null
+++ b/tools/media-grid-dups-test.js
@@ -0,0 +1,169 @@
+// The Videos grid shows a clip once, however many posts carry it: the same
+// file (or a Blossom hash's mirror), or the same words from the same person
+// with a fresh upload, or the same file uploaded twice under two hashes (a
+// note and a video event: one size to the byte) (Adam, 2026-10-22). A picture
+// that won't load leaves the Images grid instead of a broken-picture glyph. And on a big screen with a mouse, a
+// small clip opened in the pager is drawn at most 1.5× its own pixels, not
+// blown up across the monitor. Run: bun tools/media-grid-dups-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, t = 30) => {
+ const f = '/tmp/coinos-vpager-' + name + (t === 30 ? '' : '-' + t) + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', String(t), '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return f;
+};
+// (the re-uploaded post's files differ from the reposted one's: one size from
+// one person is taken for one file)
+const CLIPS = { wide: clip('wide', '320x180'), again: clip('wide', '320x180', 25), other: clip('wide', '320x180', 26), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+// the twice-uploaded clip: its own file (the ones above are all one file, so a
+// size match would take them for one clip too)
+const TWIN = '/tmp/coinos-vpager-twin.webm';
+if (!existsSync(TWIN)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', TWIN]);
+const pic = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+const picWide = pic('640x360'), picTall = pic('360x640');
+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|again|other|tall|last|twina|twinb)\.webm$/.exec(path);
+ if (m) {
+ const f = Bun.file(CLIPS[m[1]] || TWIN), 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) } });
+ }
+ const pm = /^\/pic-(wide|tall)\.png$/.exec(path);
+ if (pm) return new Response(pm[1] === 'wide' ? picWide : picTall, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } });
+ if (path === '/dead.webm' || path === '/deadpic.png') return new Response('gone', { status: 404 });
+ 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 = [
+ // one clip, three posts (a promo posted again and again)
+ post('see you at the conference ' + U('wide'), U('wide'), 100),
+ post('tickets going fast ' + U('wide'), U('wide'), 150),
+ post('last call ' + U('wide') + '?x=1', U('wide') + '?x=1', 160),
+ // the same words from the same person, uploaded afresh each time
+ post('Transylvania lightning conference 2026 ' + U('again'), U('again'), 200),
+ post('Transylvania lightning conference 2026 ' + U('other'), U('other'), 300),
+ post('the last clip ' + U('last'), U('last'), 400),
+ // one person's clip twice, as a note and as a video event, a fresh hash
+ // each (the server stores it again): different words, same bytes
+ finalizeEvent({ kind: 1, created_at: now - 410, tags: [['imeta', 'url ' + U('twina'), 'm video/webm', 'dim 320x180', 'size ' + Bun.file(TWIN).size]], content: '#goodmorning another beat ' + U('twina') }, SK),
+ finalizeEvent({ kind: 22, created_at: now - 420, tags: [['imeta', 'url ' + U('twinb'), 'm video/webm', 'dim 320x180', 'size ' + Bun.file(TWIN).size], ['title', 'Random Beat']], content: 'Random Beat' }, SK),
+ // a picture its host has deleted
+ finalizeEvent({ kind: 1, created_at: now - 125, tags: [], content: 'gone now ' + server.url.origin + '/deadpic.png' }, SK),
+ // pictures, one wide and one tall, the post saying nothing of their size
+ finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: 'a wide view ' + server.url.origin + '/pic-wide.png' }, SK),
+ finalizeEvent({ kind: 1, created_at: now - 130, tags: [], content: 'a tall view ' + server.url.origin + '/pic-tall.png' }, SK),
+ // a tall one: out of a wide screen's grid until "All shapes"
+ finalizeEvent({ kind: 1, created_at: now - 450, tags: [['imeta', 'url ' + U('tall'), 'm video/webm', 'dim 180x320']], content: 'standing up ' + U('tall') }, SK),
+ // a clip its host has since deleted (it says so: 404) — no blank tile for it
+ finalizeEvent({ kind: 1, created_at: now - 500, tags: [['imeta', 'url ' + U('dead'), 'm video/webm', 'dim 320x180']], content: 'deleted since ' + U('dead') }, 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(() => {
+ 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; };
+try {
+ await page.setViewport({ width: 1600, height: 900 });
+ 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());
+ // (the feed paints its first rows: seven of the clips' posts are among them)
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 7, null, 20000), String(await page.evaluate(() => document.querySelectorAll('.notes-feed video.note-video').length)));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ // a desktop-sized window starts on every shape (justified rows, the tall clip in)
+ check('a desktop starts on every shape', await waitFor(() => !!document.querySelector('.media-grid.justified') && [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 15000));
+ // the rest starts from the screen's way round: wide only
+ await page.evaluate(() => { localStorage.setItem('coinos-clips-fit', 'fit'); document.querySelector('.media-mode[data-mode="images"]').click(); });
+ await sleep(300);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length >= 4, null, 15000);
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 4, null, 8000);
+ await sleep(1500);
+ const tiles = await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length);
+ check('a wide screen lays the clips out one to a row, wide', await page.evaluate(() => { const g = document.querySelector('.media-grid'), t = g.querySelector('.media-tile').getBoundingClientRect(); return g.classList.contains('wide') && Math.abs(t.width / t.height - 16 / 9) < 0.05 && t.width > g.getBoundingClientRect().width * 0.95; }));
+ check('one tile per clip: the reposted file once, the re-uploaded post once, the twice-uploaded file once, the other one, none for the deleted one', tiles === 4, tiles + ' tiles: ' + (await page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile')].map((t) => t.getAttribute('data-key').split('/').pop()).join(' '))));
+ await page.evaluate(() => document.querySelector('.media-grid .media-tile').click());
+ check('the pager opens', await waitFor(() => !!document.querySelector('.mp-slide.mp-cur video.mp-media'), null, 6000));
+ check('the clip is drawn no bigger than 1.5× its pixels', await waitFor(() => { const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return v && v.videoWidth && v.getBoundingClientRect().width <= v.videoWidth * 1.5 + 1 && v.style.maxWidth; }, null, 8000),
+ JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return v && { vw: v.videoWidth, w: v.getBoundingClientRect().width, max: v.style.maxWidth }; })));
+ // "All shapes", from the pager's top bar: the tall clip is there to flick to
+ const tallShown = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); });
+ check('in the pager, the tall clip is held out on a wide screen', !(await tallShown()));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('…and "All shapes" brings it in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); }, null, 3000));
+ await page.keyboard.press('Escape');
+ check('…which, being the desktop\'s default, is kept as no setting at all', await page.evaluate(() => localStorage.getItem('coinos-clips-fit') === null));
+ check('the grid has it too', await waitFor(() => [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 5000));
+ const rows = () => page.evaluate(() => {
+ const g = document.querySelector('.media-grid'), gw = g.getBoundingClientRect().width;
+ const ts = [...g.querySelectorAll('.media-tile')].map((t) => ({ k: t.getAttribute('data-key').split('/').pop(), r: t.getBoundingClientRect() }));
+ const by = new Map(); for (const t of ts) { const y = Math.round(t.r.top); (by.get(y) || by.set(y, []).get(y)).push(t); }
+ return { justified: g.classList.contains('justified'), gw, rows: [...by.values()].map((row) => ({ ks: row.map((t) => t.k), hs: row.map((t) => Math.round(t.r.height)), ars: row.map((t) => +(t.r.width / t.r.height).toFixed(2)), w: Math.round(row.reduce((a, t) => a + t.r.width, 0) + 3 * (row.length - 1)) })) };
+ });
+ await waitFor(() => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith('/tall.webm')); return t && t.getBoundingClientRect().width < t.getBoundingClientRect().height; }, null, 5000);
+ const R = await rows();
+ const tallT = R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).find((x) => x.k === 'tall.webm');
+ check('…in justified rows: every tile its clip\'s shape (the tall one tall, the wide ones wide)', R.justified && tallT && Math.abs(tallT.ar - 0.5625) < 0.03 && R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).filter((x) => x.k !== 'tall.webm' && x.k !== 'last.webm').every((x) => Math.abs(x.ar - 16 / 9) < 0.03), JSON.stringify(R));
+ check('…one height across each row', R.rows.every((r) => Math.max(...r.hs) - Math.min(...r.hs) <= 1), JSON.stringify(R.rows.map((r) => r.hs)));
+ // the Images grid: justified rows too, each picture in its own shape
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="images"]').click());
+ const picAr = (name) => page.evaluate((n) => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith(n)); if (!t) return null; const r = t.getBoundingClientRect(); return +(r.width / r.height).toFixed(2); }, name);
+ check('the Images grid lays pictures out in justified rows, each its own shape', await waitFor(() => { const g = document.querySelector('.media-grid.justified'); if (!g) return false; const ar = (n) => { const t = [...g.querySelectorAll('.media-tile')].find((x) => x.getAttribute('data-key').endsWith(n)); if (!t) return 0; const r = t.getBoundingClientRect(); return r.width / r.height; }; return Math.abs(ar('pic-wide.png') - 16 / 9) < 0.05 && Math.abs(ar('pic-tall.png') - 9 / 16) < 0.05; }, null, 10000),
+ JSON.stringify({ wide: await picAr('pic-wide.png'), tall: await picAr('pic-tall.png') }));
+ // the deleted picture: gone from the grid, and still gone after repaints
+ // (they rewrite each tile's class list)
+ await sleep(1500);
+ await page.evaluate(() => { document.querySelector('.media-mode[data-mode="videos"]').click(); });
+ await sleep(300);
+ await page.evaluate(() => { document.querySelector('.media-mode[data-mode="images"]').click(); });
+ await sleep(1500);
+ check('a picture that won\'t load leaves the grid for good', await page.evaluate(() => {
+ const ts = [...document.querySelectorAll('.media-grid .media-tile')];
+ return ts.length >= 2 && !ts.some((t) => t.getAttribute('data-key').endsWith('deadpic.png') && getComputedStyle(t).display !== 'none');
+ }), await page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile')].map((t) => t.getAttribute('data-key').split('/').pop() + (getComputedStyle(t).display === 'none' ? '(hidden)' : '')).join(' ')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ the grid shows each clip once; small clips stay sharp on a big screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-grid-order-test.js b/tools/media-grid-order-test.js
new file mode 100644
index 0000000..9274d92
--- /dev/null
+++ b/tools/media-grid-order-test.js
@@ -0,0 +1,128 @@
+// The Videos grid's order holds for the session (Adam, 2026-10-23: clips the
+// crawl found landed in the middle of the grid and the tiles already there
+// jumped around): tiles found later go under the ones already showing, in
+// their own date order, even when they are dated between them; only a post
+// newer than the top tile goes above it. Run: bun tools/media-grid-order-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 { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/morder-');
+const NAMES = ['a', 'b', 'c', 'gap1', 'gap2', 'old1', 'old2', 'fresh'];
+for (const [i, n] of NAMES.entries()) {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', String(2 + i / 4), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+}
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const cors = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/mp4', 'accept-ranges': 'bytes' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...cors, 'content-length': String(f.size) } });
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { ...cors, 'content-range': `bytes ${a}-${b}/${f.size}` } }); }
+ return new Response(f, { headers: cors });
+ }
+ 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 page0 = server.url.origin, base = page0.replace('localhost', '127.0.0.1');
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const clip = (name, at) => finalizeEvent({ kind: 1, created_at: at, content: name + ' ' + base + '/v/' + name + '.mp4',
+ tags: [['imeta', 'url ' + base + '/v/' + name + '.mp4', 'm video/mp4', 'dim 360x640']] }, SK);
+const words = (i, at) => finalizeEvent({ kind: 1, created_at: at, tags: [], content: 'just words ' + i }, SK);
+// the page: words and three clips minutes apart; the crawl (a hundred at a
+// time) from under the page finds only two clips from long ago; the feed's
+// own next page of posts (asked as the grid is scrolled to its end) brings
+// two clips dated between c and old1
+const abc = [clip('a', now - 3000), clip('b', now - 3400), clip('c', now - 3800)];
+const gap = [clip('gap1', now - 12000), clip('gap2', now - 14000)];
+const tail = [clip('old1', now - 20000), clip('old2', now - 30000)];
+const wordsAll = []; for (let i = 0; i < 10; i++) wordsAll.push(words(i, i < 5 ? now - 100 - i * 400 : now - 4000 - (i - 5) * 400));
+const fresh = clip('fresh', now + 5);
+const all = [...wordsAll, ...abc, ...gap, ...tail].sort((x, y) => y.created_at - x.created_at);
+
+const browser = await puppeteer.launch({ relays: 'read', executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(([all, tail, fresh, pk]) => {
+ window.__crawls = 0; window.__pages = 0; window.__live = []; window.__reqs = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ const emit = (sub, ev) => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', sub, ev]) }));
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m[2] || {};
+ let out = [];
+ if ((f.kinds || []).includes(1) && (f.authors || []).includes(pk) && !f['#e'] && !f.ids) {
+ window.__reqs.push({ until: f.until, since: f.since, limit: f.limit });
+ if (f.since && !f.until) window.__live.push(() => emit(m[1], fresh));
+ else if (f.limit === 100 && f.until) { window.__crawls++; out = tail.filter((e) => e.created_at <= f.until); }
+ else { window.__pages += f.until ? 1 : 0; out = all.filter((e) => !f.until || e.created_at <= f.until).slice(0, f.limit || 30); }
+ }
+ setTimeout(() => {
+ for (const ev of out) emit(m[1], ev);
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, [all, tail, fresh, PK]);
+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, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn)) return true; await sleep(100); } return false; };
+const order = () => page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile.video')].map((t) => t.getAttribute('data-key').split('/').pop().replace('.mp4', '')));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(page0, { 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 key = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await waitFor(() => !!document.querySelector('.media-modes'), 20000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the grid: the page\'s three clips, newest first', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 20000) && (await order()).join(' ') === 'a b c', (await order()).join(' '));
+ check('the crawl finds the two from long ago, under them', await waitFor(() => window.__crawls >= 1 && document.querySelectorAll('.media-grid .media-tile.video').length === 5, 25000) && (await order()).join(' ') === 'a b c old1 old2', (await order()).join(' ') + ' crawls ' + await page.evaluate(() => window.__crawls));
+ for (let i = 0; i < 40 && !(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length === 7)); i++) {
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(500);
+ }
+ check('the feed\'s next page, asked at the grid\'s end, brings the two dated between c and old1', await page.evaluate(() => window.__pages >= 1 && document.querySelectorAll('.media-grid .media-tile.video').length === 7), (await order()).join(' ') + ' pages ' + await page.evaluate(() => window.__pages));
+ check('...shown under the tiles already there, in their own date order', (await order()).join(' ') === 'a b c old1 old2 gap1 gap2', (await order()).join(' '));
+ await page.evaluate(() => { for (const f of window.__live) f(); });
+ check('a post newer than the top tile goes above it', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 8, 20000) && (await order()).join(' ') === 'fresh a b c old1 old2 gap1 gap2', (await order()).join(' '));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ media grid: found later, shown under' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
new file mode 100644
index 0000000..7b429c9
--- /dev/null
+++ b/tools/media-moments-feed-test.js
@@ -0,0 +1,193 @@
+// Full feed integration with local media and relay acknowledgements; no events leave the test.
+// Run: bun tools/media-moments-feed-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 { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/media-');
+const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
+for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// index last (no faststart), from a server that ignores ranges
+ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
+for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
+
+ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/audio.mp3') {
+ const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); }
+ return new Response(f, { headers: { 'content-type': 'audio/mpeg' } });
+ }
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m && path !== '/v/slow.mp4') {
+ const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1);
+ // c.mp4 stands in for a 50 MB file: its server says so
+ const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file)
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } });
+ }
+ return new Response(f, { headers: { 'content-type': 'video/mp4' } });
+ }
+ if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
+ 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 base = server.url.origin;
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const FAN = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
+const notes = [
+ post(0, 'first clip, look at this ' + base + '/v/a.mp4'),
+ post(1, 'a plain post with no media at all'),
+ post(2, 'sunset over the bay ' + base + '/i/p1.jpg'),
+ post(3, base + '/v/b.mp4'),
+ post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'),
+ // the post says this one is 50 MB (it isn't; the post is believed)
+ post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]),
+ post(6, 'index last ' + base + '/v/slow.mp4'),
+];
+notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']]));
+const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN);
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((like) => {
+ const Real = window.WebSocket;
+ 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[2] || {};
+ const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : [];
+ setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return send(data);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, LIKE);
+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, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m);
+const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); };
+// which slide fills the screen, and what's playing
+const pagerState = () => page.evaluate(() => {
+ const p = document.querySelector('.media-pager');
+ if (!p) return null;
+ const sc = p.querySelector('.mp-scroll');
+ const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
+ const vids = slides.map((s) => s.querySelector('video'));
+ return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
+ playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
+ meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity };
+});
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(base, { 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 key = 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: 'Shutterbug', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
+
+
+ await page.waitForSelector('.note-audio audio');
+ await page.evaluate(() => document.querySelector('.note-audio audio').play());
+ await page.waitForSelector('.note-audio .media-timeline');
+ await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6);
+ check('published audio waveform renders in the actual feed', true);
+ await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; });
+ await sleep(100);
+ // the post's own Like carries where the clip is: no buttons of its own for that
+ const act = (sel, label) => page.evaluate(([sel, label]) => document.querySelector(sel).closest('[data-zap-post]').querySelector('.note-acts [aria-label="' + label + '"]').click(), [sel, label]);
+ check('no "at this time" buttons', await page.evaluate(() => !document.body.innerText.includes('at this time')));
+ await act('.note-audio', 'Like');
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(400);
+ const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media')));
+ check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark')));
+ check('...on the bar at its moment', await page.evaluate(() => { const m = document.querySelector('.note-audio .media-mark'); return Math.abs(parseFloat(m.style.left) - 5 / document.querySelector('.note-audio audio').duration * 100) < 0.5; }));
+ await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
+ await act('.note-audio', 'Reply');
+ await page.waitForSelector('.thread-reply-input');
+ check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05'));
+ await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.click('.thread-reply-send');
+ await sleep(500);
+ const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment')));
+ check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link')));
+ await page.evaluate(() => document.querySelector('.media-time-link').click());
+ await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5);
+ check('the comment link starts playback at its timestamp', true);
+ await click('back'); await sleep(250);
+ // a clip in the pager: its ♥ carries the moment, which then sits on its seek bar
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); const r = v.getBoundingClientRect();
+ v.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 3 })); });
+ await page.waitForSelector('.media-pager .mp-cur video', { timeout: 8000 });
+ await page.waitForFunction(() => document.querySelector('.media-pager .mp-cur video').duration > 0, { timeout: 8000 });
+ await page.evaluate(() => { const v = document.querySelector('.media-pager .mp-cur video'); v.pause(); v.currentTime = 1; });
+ await sleep(150);
+ await page.evaluate(() => document.querySelector('.media-pager .mp-cur .mp-like').click());
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(300);
+ check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1'))));
+ check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false));
+ check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline')));
+ await page.screenshot({ path: '/tmp/media-timeline-pager.png' });
+ // back in the feed: the clip has a moment now, so the moments bar is its
+ // only seek bar (no player controls), with the middle button to play it
+ await page.keyboard.press('Escape'); await sleep(500);
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.dispatchEvent(new Event('timeupdate')); });
+ await sleep(200);
+ const one = await page.evaluate(() => { const v = document.querySelector('video.note-video'); const b = v.closest('.note-video-box'); const tl = b.querySelector('.media-timeline');
+ return { controls: v.controls, own: b.classList.contains('own-bar'), bar: !!tl && getComputedStyle(tl).display !== 'none', mark: !!b.querySelector('.media-mark'), pp: getComputedStyle(b.querySelector('.vid-pp')).display }; });
+ check('in the feed, a clip with a moment has one seek bar: the moments bar, no player controls', !one.controls && one.own && one.bar && one.mark, JSON.stringify(one));
+ check('...and the middle button plays it, on a touch screen too', one.pp === 'flex', JSON.stringify(one));
+ const vid2 = await page.evaluate(() => { const b = document.querySelector('.note-video-box .vid-pp').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
+ await page.mouse.click(vid2.x, vid2.y); await sleep(600);
+ check('...which plays it in place', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager')));
+ check('no browser exceptions', errs.length === 0, errs.join(' | '));
+ await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
+} finally { await browser.close(); server.stop(); }
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-servers-test.js b/tools/media-servers-test.js
new file mode 100644
index 0000000..c5cad2f
--- /dev/null
+++ b/tools/media-servers-test.js
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { parseMediaServers, customMediaServers, mediaServers, saveMediaServers, resetMediaServers } from '../src/media-servers.js';
+import { uploadPublicMedia, uploadStream, PUBLIC_MEDIA_SERVERS } from '../src/media-upload.js';
+const stored = new Map();
+globalThis.localStorage = { getItem: k => stored.get(k) ?? null, setItem: (k, v) => stored.set(k, v), removeItem: k => stored.delete(k) };
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+assert.deepEqual(saveMediaServers('https://FIRST.example/\nhttps://first.example https://second.example/'), ['https://first.example', 'https://second.example']);
+for (const bad of ['', 'http://host.test', 'https://host.test/path', 'https://user:[email protected]', 'https://host.test?x=1', 'https://host.test#x', 'https://good.test nope', Array.from({length:7}, (_,i)=>`https://host${i}.test`).join('\n')]) {
+ assert.throws(() => saveMediaServers(bad));
+ assert.equal(customMediaServers()[0], 'https://first.example', 'invalid input must not overwrite saved servers');
+}
+const calls = [];
+const fetcher = async (url, opts) => {
+ calls.push(url);
+ if (url.startsWith('https://first.')) return new Response('', {status:503});
+ if (opts.method === 'HEAD') return new Response('');
+ return Response.json({url: 'https://second.example/' + 'a'.repeat(64)});
+};
+const sign = async e => e;
+await uploadPublicMedia(new File(['picture'], 'photo.png', {type:'image/png'}), sign, {fetcher});
+assert(calls.some(u => u.startsWith('https://second.')));
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+calls.length = 0;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['video.mp4', new File(['video'], 'video.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nvideo.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, sign, {fetcher});
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+await assert.rejects(uploadPublicMedia(new File(['x'], 'x'), sign, {fetcher: async url => {
+ assert(calls.includes(url), 'must not fall back to public hosts');
+ return new Response('', {status:503});
+}}));
+// Exercise the attachment uploader used by DMs, communities and White Noise.
+const source = await Bun.file('src/features/messages.js').text();
+const start = source.indexOf(' async function uploadEncrypted(');
+const end = source.indexOf(' const imetaTag', start);
+const encryptedCalls = [];
+const uploadEncrypted = new Function('mediaServers', 'MEDIA_SERVERS', 'fetch', 't',
+ source.slice(start, end) + '\nreturn uploadEncrypted;')(
+ mediaServers, ['https://public.example'], async (url, init) => {
+ encryptedCalls.push({url, init});
+ return Response.json({url: url.replace('/upload', '/cipher')});
+ }, key => key);
+const cipher = new Uint8Array([9, 8, 7]);
+const urls = await uploadEncrypted({pubkey:'a'.repeat(64), signer:{signEvent: sign}}, {cipher, x:'b'.repeat(64)});
+assert.deepEqual(urls, ['https://first.example/cipher', 'https://second.example/cipher']);
+assert(encryptedCalls.every(c => c.init.body === cipher && c.init.headers['content-type'] === 'application/octet-stream'));
+resetMediaServers();
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+console.log('✓ Media server validation, persistence, custom-only file/HLS/encrypted attachment uploads, failover, and reset');
diff --git a/tools/media-timeline-test.js b/tools/media-timeline-test.js
new file mode 100644
index 0000000..3b812ff
--- /dev/null
+++ b/tools/media-timeline-test.js
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js';
+import { buildZapRequest } from '../src/lnurl.js';
+import puppeteer from './puppeteer.js';
+const url = 'https://example.com/song.mp3';
+const moment = mediaMoment(url, 65.9);
+assert.deepEqual(moment, { url, seconds: 65 });
+for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null);
+assert.equal(mediaMoment('javascript:alert(1)', 0), null);
+assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment);
+assert.equal(readMoment({ tags: [['media', url, '']] }), null);
+const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e });
+assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e');
+assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment);
+assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null);
+assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]);
+assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null);
+assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]);
+const wav = new Uint8Array(44 + 16000);
+const dv = new DataView(wav.buffer);
+const word = (at, text) => wav.set(new TextEncoder().encode(text), at);
+word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt ');
+dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true);
+for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true);
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } });
+ if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } });
+ if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } });
+ if (path === '/style.css') return new Response(Bun.file('src/style.css'));
+ return new Response('<!doctype html><link rel="stylesheet" href="/style.css"><body>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const decoded = await page.evaluate(async () => {
+ const { loadWaveform } = await import('/module.js');
+ const peaks = await loadWaveform('/wave.wav');
+ return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') };
+ });
+ assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null);
+ const result = await page.evaluate(async () => {
+ const { mediaTimeline } = await import('/module.js');
+ const h = (tag, attrs = {}, ...children) => {
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); }
+ el.append(...children); return el;
+ };
+ const player = h('audio');
+ Object.defineProperty(player, 'duration', { value: 120 });
+ let at = 0;
+ Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } });
+ const url = 'https://example.com/song.mp3';
+ const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] };
+ const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }];
+ const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, glyph: x => x.icon, label: x => x });
+ document.body.append(player, box);
+ player.dispatchEvent(new Event('timeupdate'));
+ const wave = box.querySelectorAll('.media-wave i').length;
+ const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input'));
+ const seek = at;
+ box.querySelector('.media-mark').click();
+ const marker = at;
+ items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' });
+ player.dispatchEvent(new Event('mediaactivity'));
+ window.fixture = box;
+ return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, left: [...box.querySelectorAll('.media-mark')].map(m => m.style.left), buttons: box.querySelectorAll('button:not(.media-mark)').length, aria: range.getAttribute('aria-valuetext') };
+ });
+ assert.equal(result.wave, 4);
+ assert.equal(result.seek, 60);
+ assert.equal(result.marker, 30);
+ assert.equal(result.markers, 2);
+ assert.deepEqual(result.left, ['25%', '58.3333%']); // each on the bar at its moment
+ assert.equal(result.buttons, 0); // no "at this time" buttons
+ await page.setViewport({ width: 360, height: 740 });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ await page.screenshot({ path: '/tmp/media-timeline-mobile.png' });
+ console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, markers placed at their moments and mobile layout');
+} finally { await browser.close(); server.stop(); }
diff --git a/tools/media-upload-identity-test.js b/tools/media-upload-identity-test.js
new file mode 100644
index 0000000..6d7e023
--- /dev/null
+++ b/tools/media-upload-identity-test.js
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure';
+import { mediaUploadSigner, uploadPublicMedia, uploadStream, MediaUploadError } from '../src/media-upload.js';
+
+const walletKey = generateSecretKey(), loginKey = generateSecretKey();
+let walletSigns = 0, loginSigns = 0, resumes = 0;
+const wallet = { nostrSign: event => { walletSigns++; return finalizeEvent(event, walletKey); } };
+const signer = { signEvent(event) { loginSigns++; return finalizeEvent(event, loginKey); } };
+const login = {pubkey: getPublicKey(loginKey), signer};
+let id = login, resumed = signer;
+const hook = name => name === 'nostrLoginIdentity' ? id : (resumes++, resumed);
+const file = new File(['image'], 'test.png', {type: 'image/png'});
+const seen = [];
+// Haven's owner/allowlist rule: only this login key can upload.
+const fetcher = async (url, init) => {
+ const event = JSON.parse(atob(init.headers.authorization.slice(6)));
+ assert(verifyEvent(event));
+ seen.push(event.pubkey);
+ if (event.pubkey !== login.pubkey) return new Response('', {status:403, headers:{'x-reason':'only media signed by whitelisted pubkeys are allowed'}});
+ return Response.json({url:'https://haven.example/' + event.tags.find(t => t[0] === 'x')[1]});
+};
+const options = {servers:['https://haven.example'], fetcher};
+await assert.rejects(uploadPublicMedia(file, event => wallet.nostrSign(event), options), e => e instanceof MediaUploadError && e.code === 'unauthorized');
+const walletBefore = walletSigns;
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(walletSigns, walletBefore);
+assert.equal(loginSigns, 1);
+assert.equal(resumes, 0);
+id = {...login, signer:null};
+await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options);
+assert.equal(resumes, 1);
+resumed = null;
+await assert.rejects(mediaUploadSigner(wallet, hook), e => e.code === 'no-identity');
+assert.equal(walletSigns, walletBefore, 'an unavailable login signer must never fall back to the wallet');
+id = null;
+const nativeEvent = await (await mediaUploadSigner(wallet, hook))({kind:24242, created_at:1, tags:[], content:''});
+assert.equal(nativeEvent.pubkey, getPublicKey(walletKey));
+id = login;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['clip.mp4', new File(['clip'], 'clip.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nclip.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, await mediaUploadSigner(wallet, hook), options);
+assert(seen.slice(1).every(pk => pk === login.pubkey));
+
+// Exercise the app's actual routing for both regular files and HLS videos.
+const app = await Bun.file('src/app.js').text();
+const start = app.indexOf('async function publicMediaUpload(file) {');
+const end = app.indexOf('// Whether the wizard', start);
+const body = app.slice(start, end).replace("await import('./video-compress.js')", 'video');
+const stream = {dim:'10x10',seconds:2};
+const signs = [];
+const ui = {};
+const upload = new Function('wallet','featureHook','mediaUploadSigner','uploadPublicMedia','uploadStream','MediaUploadError','mediaProgress','ui','t','video', body + '\nreturn publicMediaUpload;')(
+ wallet, hook, mediaUploadSigner,
+ async (file, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'photo'; },
+ async (stream, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'video'; },
+ MediaUploadError, () => {}, ui, key => key,
+ {streamVideo: async () => stream});
+assert.equal(await upload(file), 'photo');
+assert.equal((await upload(new File(['video'], 'clip.mp4', {type:'video/mp4'}))).url, 'video');
+assert(signs.every(e => e.pubkey === login.pubkey));
+assert.equal(ui.mediaProgress, null);
+console.log('✓ Haven-style allowlist: login identity, signer resume, no wallet fallback, native identity and app file/HLS routing');
diff --git a/tools/names-invoice-expiry-test.js b/tools/names-invoice-expiry-test.js
new file mode 100644
index 0000000..511d212
--- /dev/null
+++ b/tools/names-invoice-expiry-test.js
@@ -0,0 +1,70 @@
+// Receive → More → Lightning invoice: the invoice says to keep coinos open
+// until it's paid (the server only finishes a receive while the wallet is
+// there to take it) and gives way to "expired" when its hour is up.
+// The Ark side is faked. Run: bun tools/names-invoice-expiry-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.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 { namesFeature } from './src/features/names.js';
+${domHelper}
+const state = { names: { byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } } };
+const ui = { screen: 'wallet', namesMore: 'bolt11' };
+const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => e, nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' };
+const ctx = {
+ h, ui, wallet, render, toast: () => {}, copy: () => {}, copyBtn: (x, l) => h('button', { class: 'btn-sm' }, l), brandHeader: () => null, goBack: () => {}, setAccount: () => {},
+ hook(name, ...a) {
+ if (name === 'arkReady') return true;
+ if (name === 'arkMakeInvoice') return Promise.resolve({ invoice: 'lnbc10u1testinvoice', amountSat: a[0], expiresAt: Date.now() + (window.__life || 3600_000) });
+ return null;
+ },
+};
+const feature = namesFeature(ctx);
+function render() { document.querySelector('#app').replaceChildren(feature.receiveModes()[0].render(h('div', { class: 'seg' }))); }
+window.test = { render };
+render();
+`;
+const bundle = await Bun.build({
+ entrypoints: ['names-inv-entry'], target: 'browser',
+ plugins: [{ name: 'names-inv', setup(build) {
+ build.onResolve({ filter: /^names-inv-entry$/ }, () => ({ path: 'entry', namespace: 'names-inv' }));
+ build.onLoad({ filter: /.*/, namespace: 'names-inv' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+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 make = async () => {
+ await page.evaluate(() => { const i = document.querySelector('input[type=number]'); i.value = '1000'; i.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /create/i.test(b.textContent)).click());
+ await pause(100);
+};
+try {
+ await page.goto(server.url.href);
+ await page.waitForSelector('input[type=number]');
+ await make();
+ check('the invoice shows', /lnbc10u1testinvoice/.test(await text()));
+ check('...saying to keep coinos open until it\'s paid, and that it lasts an hour', /Keep coinos open until this is paid/.test(await text()) && /goes back to the sender/.test(await text()) && /expires in an hour/.test(await text()));
+ await page.evaluate(() => { window.__life = 1500; document.querySelector('.btn-sm:last-child').click(); });
+ await pause(50);
+ await page.evaluate(() => test.render());
+ await make();
+ check('a fresh one shows its QR', !!(await page.$('svg')) && !/expired/.test(await text()));
+ await pause(2300);
+ check('when its time is up it says expired, the QR gone', /This invoice has expired/.test(await text()) && !(await page.$('svg')) && !/lnbc10u1testinvoice/.test(await text()));
+ check('...with a button for a new one', await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'New invoice')));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ invoice: keep open, expires');
+} 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/notif-faces-test.js b/tools/notif-faces-test.js
index b45b120..a1c1c8f 100644
--- a/tools/notif-faces-test.js
+++ b/tools/notif-faces-test.js
@@ -73,6 +73,8 @@ try {
const ME = getPublicKey(privateKeyFromSeedWords(mn));
// a cached notifications list: five people reacted to my post
const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) }));
+ // and one zapped my profile: no post to open
+ items.unshift({ id: 'z'.repeat(64), what: 'zap', actor: ACTORS[1].pk, target: null, hint: [], sats: 421, text: 'thanks!', ts: now - 30 });
await page.evaluate(([k, me, items]) => {
const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
st.notifs = items; st.notifsPk = me; st.notifsV = 2;
@@ -100,6 +102,10 @@ try {
await sleep(150); // the first paint, not a settled one
const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url')));
check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces));
+ // a zap on my profile (no post): the tap opens the zapper's profile — it did nothing
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => r.textContent.includes('thanks!')).querySelector('.grow').click());
+ await sleep(500);
+ check('tapping a profile zap opens the zapper\'s profile', await page.evaluate((pk) => !document.querySelector('.alert-row') && /npub1|nprofile1/.test(location.pathname) && document.body.innerText.includes('Actor 1'), ACTORS[1].pk), await page.evaluate(() => location.pathname));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed');
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..7e85ee1 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -5,7 +5,8 @@ import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import { respondFromBg } from '../src/nwc-respond.js';
+import { respondFromBg, tendLnPays } from '../src/nwc-respond.js';
+import { maybeBolt11 } from '../src/ark/lightning.js';
let fails = 0;
const check = (name, ok, detail = '') => {
@@ -46,11 +47,16 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) {
function harness({ rec = baseRec(), recs = null, answered = false } = {}) {
const published = [];
+ const declined = []; // wait replies left with the notifier (sent only if nobody answers)
const fetchFn = async (url, opts) => {
if (url.includes('/publish')) {
published.push(JSON.parse(opts.body).event);
return { ok: true, json: async () => ({ ok: true }) };
}
+ if (url.includes('/decline')) {
+ declined.push(JSON.parse(opts.body).event);
+ return { ok: true, json: async () => ({ ok: true, scheduled: true }) };
+ }
if (url.includes('/claim')) {
return { ok: true, json: async () => ({ ok: true }) };
}
@@ -72,7 +78,7 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) {
? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk))
: nip04.decrypt(clientSk, conn.servicePk, ev.content));
};
- return { deps, published, decryptReply };
+ return { deps, published, declined, decryptReply };
}
const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would
@@ -118,15 +124,37 @@ console.log('\n[merchant methods (BTCPay)]');
check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs));
}
{
- const { deps } = harness(); // a mirror without receive keys
- check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false);
+ // PullCap 2026-10-24: lookups of a send in flight came back "not found".
+ // Every copy of the connection is asked; an outgoing send answers as one.
+ const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g';
+ const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e';
+ const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' };
+ const live = baseRec(); live.updated = 1;
+ live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV });
+ const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps);
+ const r = decryptReply(published[0]).result || {};
+ check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r));
+ live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage;
+ const t0 = Date.now();
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 });
+ const r2 = decryptReply(published[1]).result || {};
+ check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`);
+}
+{
+ const { deps, declined, decryptReply } = harness(); // a mirror without receive keys
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps);
+ const r = declined.length ? decryptReply(declined[0]) : null;
+ check('make_invoice on an old mirror → wake the user, wait reply left behind', handled === false && declined.length === 1 && r.result_type === 'make_invoice' && r.error?.code === 'OTHER', JSON.stringify(r));
}
console.log('\n[guards]');
{
- const { deps } = harness();
+ const { deps, published, declined } = harness();
const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300));
- check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false);
+ // a push that sat at APNs past the request window: nothing to answer, and
+ // no "Payment request" for it either (true = handled quietly)
+ check('stale request ignored quietly', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === true && !published.length && !declined.length);
}
{
const { deps } = harness();
@@ -166,16 +194,150 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z
{
const rec = baseRec();
rec.mgr.vtxos = []; // nothing spendable in the mirror
- const { deps } = harness({ rec });
+ const { deps, published, declined, decryptReply } = harness({ rec });
const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
- check('empty mirror → wake the user', handled === false);
+ const r = declined.length ? decryptReply(declined[0]) : null;
+ // the wait reply is NOT published by the worker: the notifier holds it and
+ // sends it only if nobody answers — a sibling's payment can't be shouted over
+ check('empty mirror → wake the user, wait reply left with the notifier', handled === false && published.length === 0 && declined.length === 1
+ && r.result_type === 'pay_invoice' && r.error?.code === 'OTHER' && /open the app/.test(r.error.message), JSON.stringify(r));
+ check('…addressed to the request', declined[0].kind === 23195 && declined[0].tags.some((t) => t[0] === 'e'));
}
{
const rec = baseRec();
delete rec.keys['5']; // change index outside the mirrored window
- const { deps } = harness({ rec });
+ const { deps, declined } = harness({ rec });
const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
- check('exhausted key window → wake the user', handled === false);
+ check('exhausted key window → wake the user, wait reply left behind', handled === false && declined.length === 1);
+}
+
+console.log('\n[pay outcome]');
+{
+ // a payment that goes through: the worker hands the push handler a
+ // "Payment sent" heads-up (unless the user turned those off)
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const orig = { init: ArkManager.prototype.init, pay: ArkManager.prototype.payLnInvoice, wait: ArkManager.prototype.awaitLnPay, act: ArkManager.prototype.lnAction };
+ const done = { id: 'ln-1', step: 'done', amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ ArkManager.prototype.payLnInvoice = async () => 'ln-1';
+ ArkManager.prototype.awaitLnPay = async () => done;
+ ArkManager.prototype.lnAction = () => done;
+ try {
+ const { deps, published, decryptReply } = harness();
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('a paid invoice answers with the preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === done.preimage, JSON.stringify(handled));
+ check('…and asks for a "Payment sent" notification', handled && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ const rec = baseRec(); rec.prefs = { paySent: false };
+ const h2 = harness({ rec });
+ const quiet = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, h2.deps);
+ check('payments-sent notifications off → paid quietly', quiet === true && h2.published.length === 1);
+ } finally {
+ Object.assign(ArkManager.prototype, { init: orig.init, payLnInvoice: orig.pay, awaitLnPay: orig.wait, lnAction: orig.act });
+ }
+}
+
+console.log('\n[sends begun on an earlier wake]');
+// The worker died mid-payment on an earlier wake: the action sits in the
+// mirror at 'created' with its coin reserved (PullCap, 2026-10-23).
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const HASH = maybeBolt11(INV21).paymentHash;
+ const PRE = 'cd'.repeat(32);
+ const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ // a fresh attempt at an invoice with an action on the books is refused…
+ ArkManager.prototype.payLnInvoice = async function (invoice) {
+ const hash = maybeBolt11(invoice).paymentHash;
+ if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress');
+ const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ this.state.actions.push(a); this.storage.save(this.state);
+ return a.id;
+ };
+ // …and a drive takes the stuck one through to done, freeing its change
+ ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); };
+ ArkManager.prototype.driveLn = async function (id) {
+ const a = this.state.actions.find((x) => x.id === id);
+ if (a && a.step !== 'done') {
+ a.step = 'done'; a.preimage = PRE;
+ for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable';
+ this.storage.save(this.state);
+ }
+ return a;
+ };
+ ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); };
+ const stuckRec = (hash) => {
+ const rec = baseRec();
+ rec.mgr.vtxos[0].state = 'pending';
+ rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 });
+ rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }];
+ return rec;
+ };
+ try {
+ {
+ // the SAME invoice asked again: finish that send, answer with ITS preimage
+ const rec = stuckRec(HASH);
+ const log = [];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) });
+ check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled));
+ check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends));
+ check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent');
+ check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | '));
+ }
+ {
+ // a send that settled earlier (in the app, say): its preimage again,
+ // nothing new on the spend log
+ const rec = baseRec();
+ rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE });
+ const { deps, published, decryptReply } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE);
+ check('…without a second spend', (rec.spends || []).length === 0);
+ }
+ {
+ // a stuck send for ANOTHER invoice holds the only coin: finish it first,
+ // then pay the new request from the freed coin
+ const rec = stuckRec('ee'.repeat(32));
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled));
+ check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32));
+ check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ }
+ {
+ // any wake at all finishes it (a lookup, a push for something else)
+ const rec = stuckRec(HASH);
+ const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} });
+ check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices));
+ check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0);
+ }
+ } finally {
+ Object.assign(ArkManager.prototype, orig);
+ }
+}
+
+console.log('\n[a send that fails]');
+// The ASP refused the cosign (a coin at the depth cap, PullCap 2026-10-29):
+// the error is left with the notifier at once, not waited out in a worker
+// iOS stops before it speaks.
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const names = ['init', 'payLnInvoice'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ try {
+ for (const [why, code] of [
+ ['insufficient ark balance (259 sat have a history too long to spend and are too few to renew; receive more to keep paying)', 'INSUFFICIENT_BALANCE'],
+ ['ln-pay failed: route not found', 'PAYMENT_FAILED'],
+ ]) {
+ ArkManager.prototype.payLnInvoice = async function () { throw new Error(why); };
+ const { deps, published, declined, decryptReply } = harness();
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, errGraceMs: 60_000 });
+ const r = declined.length ? decryptReply(declined[0]) : null;
+ check(`${code}: the error is handed to the notifier, nothing waited out here`, handled === true && declined.length === 1 && !published.length && r.error?.code === code && r.error.message === why, JSON.stringify(r));
+ }
+ } finally { Object.assign(ArkManager.prototype, orig); }
}
console.log('\n[network fit]');
@@ -239,5 +401,149 @@ 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, grid) { calls.push(['ensure', margin, max, grid]); return 1; };
+ try {
+ const rec = baseRec();
+ rec.key4 = 'ff'.repeat(32);
+ rec.custody = 'self'; // a renewing tier: the worker covers bare coins
+ 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, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), 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));
+
+ // the default tier — coinos holds the coins — registers no order at all,
+ // however many keys the window holds (the claim of an order that already
+ // ran still happens: it was priced and promised when registered)
+ calls.length = 0;
+ const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} });
+ check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+ calls.length = 0;
+ const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'exit'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} });
+ check('exit tier → the same backstop as self (depth is the app\'s business)', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 36, 1, 48]]), JSON.stringify(calls));
+
+ calls.length = 0;
+ const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; 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..50ffe4f
--- /dev/null
+++ b/tools/pager-caption-test.js
@@ -0,0 +1,138 @@
+// 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);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
+ 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]);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
+ 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-close-test.js b/tools/pager-close-test.js
index 73aa15b..a7c6c12 100644
--- a/tools/pager-close-test.js
+++ b/tools/pager-close-test.js
@@ -84,6 +84,8 @@ const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media-
const openFirst = async () => {
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
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-eager-test.js b/tools/pager-eager-test.js
index 15a5076..3d980fb 100644
--- a/tools/pager-eager-test.js
+++ b/tools/pager-eager-test.js
@@ -88,7 +88,7 @@ async function run(saveData, slow = false) {
const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w);
try {
await page.setViewport({ width: 390, height: 844 });
- pace = slow ? 375000 : 0;
+ pace = slow ? 375000 : 12_500_000; // 3 Mbps, or 100 (a loopback's speed is no link: Chrome reads a warm player's whole file before it can stop)
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);
@@ -117,20 +117,25 @@ async function run(saveData, slow = false) {
await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000);
const r = await feedRow('clip c0 here');
log = log.filter((e) => e.clip === 'c0');
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await sleep(slow ? 8000 : 3000);
const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
let head1 = 0;
if (!saveData) {
- check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2]));
+ check('the next clip has a warm player: its file, read to its first frame and paused', c1 && c1.src && c1.preload === 'metadata' && !c1.playing && !c1.cover && await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); return s.querySelector('video.mp-media').readyState >= 2; }), JSON.stringify(c1));
+ check('…the one after waits on its picture, no player yet', c2 && !c2.src && !c2.cover, JSON.stringify(c2));
const hs = heads('c1'), h2 = heads('c2');
head1 = hs.length ? hs[0].end + 1 : 0;
const head2 = h2.length ? h2[0].end + 1 : 0;
// before any measure of the link: two seconds' worth (the second clip ahead, half)
check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`);
check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`);
- check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ // the warm player reads on from the cached opening to its first frame and a little past, then stops
+ check('ahead of time: the next clip\'s opening and a few MB more for its warm player, the one after its opening only', sent('c1') <= head1 + 4 * 1024 * 1024 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ check('…the warm player took the opening from the cache, not the host', !log.some((e) => e.clip === 'c1' && e.dest === 'video' && e.start === 0), log.filter((e) => e.clip === 'c1').map((e) => e.dest + ':' + e.start).join(' '));
} else {
check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1')));
}
@@ -139,14 +144,21 @@ async function run(saveData, slow = false) {
const t0 = Date.now();
if (!saveData) {
check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms');
- const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video');
- check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1);
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 3000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('…from its warm player, the clock says', st && st.warm && st.ms < 1000, JSON.stringify(st));
await sleep(1500);
const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0;
check('…the next one gets its head start', h3.length === 1);
// the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap
if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3));
else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3));
+ // the clip just left keeps its player: the swipe back is warm too
+ check('the clip just left keeps its player', !!(await slide('c0'))?.src);
+ await page.keyboard.press('ArrowUp');
+ await waitFor(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).length >= 2, null, 5000);
+ const back = await page.evaluate(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).at(-1));
+ check('…so swiping back to it starts warm', back && back.warm && back.ms < 1000, JSON.stringify(back));
} else {
await sleep(3000);
const s = await slide('c1');
diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js
new file mode 100644
index 0000000..0d477b7
--- /dev/null
+++ b/tools/pager-first-frame-test.js
@@ -0,0 +1,126 @@
+// 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 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
+ 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
index 76e2764..42a5344 100644
--- a/tools/pager-loading-test.js
+++ b/tools/pager-loading-test.js
@@ -21,7 +21,7 @@ function fixture(kind = 'images', grid = false) {
};
const src = { c: cache, more, done: () => false };
const ctx = vm.createContext({
- pager: p, pagerGroups: () => groups.slice(0, count),
+ pager: p, pagerGroups: () => groups.slice(0, count), pagerFound: () => groups.slice(0, count),
pagerSource: () => grid ? null : src, feedNow: () => cache, mediaIndex: () => index, feedDef: () => ({}),
crawlMedia: (c, k) => { assert.equal(c, cache); assert.equal(k, kind); return more(); },
growPager: () => {}, Date: { now: () => clock },
diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js
new file mode 100644
index 0000000..332eb53
--- /dev/null
+++ b/tools/pager-no-poster-test.js
@@ -0,0 +1,125 @@
+// 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);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
+ 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-prime-test.js b/tools/pager-prime-test.js
new file mode 100644
index 0000000..4989790
--- /dev/null
+++ b/tools/pager-prime-test.js
@@ -0,0 +1,133 @@
+// A warm player's sound is started before the swipe lands (an Android
+// phone held a still picture ~250 ms on a player's first play with sound):
+// the next clip plays at a volume nobody hears until its picture moves,
+// and pauses there, a frame or two in — not seeked back. Clips over 25 MB
+// aren't (once played, a player reads the whole file): pager-eager-test.
+// Run: bun tools/pager-prime-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// that); on a link slower than the clip, more — to the 4 MB cap. With Data
+// Saver on, the pager is careful again: such a clip waits for a tap and
+// nothing loads ahead.
+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, statSync } 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; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+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 });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ 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 seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.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 vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ 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: 'Prime 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());
+ await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
+ const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; });
+ await sleep(800);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
+ await page.mouse.click(r.x, r.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1')));
+ const c1 = await vid('c1');
+ check('...its volume back to full, and quiet until it\'s on screen', c1.volume === 1 && c1.muted, JSON.stringify(c1));
+ await page.keyboard.press('ArrowDown');
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 5000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('swiped to, it plays with sound, primed', st && st.primed && st.warm && st.ms < 1000 && await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return !v.muted && v.volume === 1 && !v.paused; }), JSON.stringify(st));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a warm player has its sound going before the swipe lands' : '\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-rotate-test.js b/tools/pager-rotate-test.js
index 9912b7a..9dcc7e3 100644
--- a/tools/pager-rotate-test.js
+++ b/tools/pager-rotate-test.js
@@ -91,6 +91,8 @@ async function run(unmuteFirst) {
await sleep(800);
}
const at = await rowOf(); await sleep(300);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000);
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js
new file mode 100644
index 0000000..50863ba
--- /dev/null
+++ b/tools/pager-seek-hide-test.js
@@ -0,0 +1,155 @@
+// 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. And at rest it's out of sight too while
+// the clip plays (Adam, 2026-10-19): a tap near the foot of the screen brings
+// it up for a few seconds without pausing, and a paused clip shows it. 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 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
+ 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 seek bar comes when asked for]');
+ check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
+ await waitFor(() => !!document.querySelector('.mp-slide.mp-cur .mp-seek.ready'), null, 6000);
+ await sleep(400);
+ const curBar = () => page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return { opacity: getComputedStyle(b).opacity, pe: getComputedStyle(b).pointerEvents, playing: !v.paused }; });
+ check('…its seek bar out of sight (and out of reach) while it plays', (await curBar()).opacity === '0' && (await curBar()).pe === 'none', JSON.stringify(await curBar()));
+ await page.mouse.click(195, 844 - 70); // near the foot, where the bar sits
+ check('a tap near the foot brings it up, the clip playing on', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
+ check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' '));
+ check('…and it goes again a few seconds later', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 6000), JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ check('a tap mid-screen pauses the clip, and the bar is up', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && !(await curBar()).playing, JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ // (the test's mouse moved to click, which a desktop answers with the bar for a moment)
+ check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 3500) && (await curBar()).playing, JSON.stringify(await curBar()));
+ // a mouse (a desktop) brings it up by moving, and it fades once the mouse is still
+ await page.waitForFunction(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', { timeout: 6000 }).catch(() => {});
+ for (let i = 0; i < 5; i++) { await page.mouse.move(150 + i * 20, 300 + i * 10); await sleep(60); }
+ check('a mouse moving over the clip brings the bar up, the clip playing on', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '1', null, 1500) && (await curBar()).playing, JSON.stringify(await curBar()));
+ const stillAt = Date.now();
+ check('…and it fades once the mouse is still', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 4000), JSON.stringify(await curBar()));
+ check('…a couple of seconds later, not the tap\'s longer wait', Date.now() - stillAt < 3200, (Date.now() - stillAt) + ' ms');
+ await page.mouse.click(195, 844 - 70); // up again, for the glide
+ 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);
+ await sleep(600);
+ check('…whose seek bar stays out of sight until asked for', await page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '0'; }), 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.mouse.click(195, 844 - 70); await sleep(300); // the bar up first
+ 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', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && !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..657ea75
--- /dev/null
+++ b/tools/pager-swipe-fly-test.js
@@ -0,0 +1,185 @@
+// 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 and goes no further (no going round to the first); a tap
+// mid-flight stops it on the post most on screen, and a swipe back from
+// there goes to the one before.
+// Run: bun tools/pager-swipe-fly-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+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[the ends are ends]');
+ while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); }
+ const before = await at();
+ await swipe(600, 80, 3); await settled();
+ check('from post ' + before + ', a flick whose flight would be past the end lands on the last post', before === 7 && (await at()) === N, String(await at()));
+ await swipe(600, 80, 3); await settled();
+ check('…a flick on from the last stays there (no going round to the first)', (await at()) === N, String(await at()));
+ await page.keyboard.press('ArrowDown'); await settled();
+ check('…and so does ↓', (await at()) === N, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await swipe(-600, 80, 3); await settled();
+ check('a flick back up from the first stays on the first', (await at()) === 1, String(await at()));
+ await swipe(320, 130); await settled();
+ check('a gentle swipe from the first: the second, as ever', (await at()) === 2, String(await at()));
+
+ console.log('\n[a busy main thread: the speed is the finger\'s, not the handling\'s]');
+ // (the dispatch loop runs all the events in one go: built on schedule, each stamped as it was made)
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await queued(200, 200, 8, 'burst'); await settled();
+ check('from post 1, a gentle 200px swipe over 200ms, its moves handled in one burst: one post on', (await at()) === 2, String(await at()));
+ await queued(600, 80, 4, 'stall'); await settled();
+ check('a fast flick whose last move was handled 300ms late still flies', typeof (await at()) === 'number' && (await at()) >= 5, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+
+ console.log('\n[a tap mid-flight stops it on the post most on screen]');
+ // the post most on screen as the finger comes down (its number), then the touch, in one go
+ const down = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'), y = sc.scrollTop;
+ const g = [...sc.querySelectorAll(':scope > .mp-group:not(.mp-wait):not(.mp-off)')].reduce((b, x) => (Math.abs(x.offsetTop - y) < Math.abs(b.offsetTop - y) ? x : b));
+ const target = window.__tt = document.elementFromPoint(195, 500), t = new Touch({ identifier: 2, target, clientX: 195, clientY: 500, pageX: 195, pageY: 500 });
+ target.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], changedTouches: [t], targetTouches: [t] }));
+ return Number(/p(\d+)\.png/.exec(g.querySelector('.mp-slide').getAttribute('data-k'))[1]);
+ });
+ // the same finger moving (to y), or lifted there
+ const finger = (y, up) => page.evaluate(([y, up]) => {
+ const target = window.__tt, t = new Touch({ identifier: 2, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ target.dispatchEvent(new TouchEvent(up ? 'touchend' : 'touchmove', { bubbles: true, cancelable: true, touches: up ? [] : [t], changedTouches: [t], targetTouches: up ? [] : [t] }));
+ }, [y, up]);
+ await swipe(600, 80, 3);
+ await sleep(150);
+ const flying = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
+ const most = await down();
+ await sleep(60); await finger(500, true);
+ await sleep(250);
+ check('a tap mid-flight: on the post most on screen in a moment', flying > 0 && (await at()) === most, most + ' wanted, ' + (await at()));
+ await settled();
+ check('…and stays there', (await at()) === most, String(await at()));
+ check('…the tap only stopped it (no details brought up)', !(await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))));
+
+ console.log('\n[a 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-tail-test.js b/tools/pager-tail-test.js
index b294b8f..9d537e1 100644
--- a/tools/pager-tail-test.js
+++ b/tools/pager-tail-test.js
@@ -103,6 +103,8 @@ try {
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at());
check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail()));
diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js
index 09f761a..4de87df 100644
--- a/tools/pager-tap-slop-test.js
+++ b/tools/pager-tap-slop-test.js
@@ -76,6 +76,7 @@ try {
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ localStorage.setItem('coinos-clips-fit', 'all'); // (the wide clip stays in: this test is about the finger, not the shapes)
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js
new file mode 100644
index 0000000..7cfaa45
--- /dev/null
+++ b/tools/picture-pager-ready-test.js
@@ -0,0 +1,102 @@
+// The picture pager works like the video pager (Adam, 2026-10-17: "image
+// pager should work similar. we need the tall only chip there too"): a
+// picture post joins the carousel once its picture is decoded, so a step
+// passes over one still on its way; the shapes the wrong way round for
+// the screen stay out, with the "Tall only" / "All shapes" toggle in the
+// top bar; and with fewer than ten ready and more still warming, no
+// counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+// t1..t4 tall, w1 wide, sq square; slow (tall) is held back until released
+const PICS = { t1: png('300x400'), t2: png('300x420'), slow: png('300x440'), t3: png('300x460'), w1: png('480x270'), sq: png('320x320'), t4: png('300x480') };
+let release; const held = new Promise((r) => (release = r));
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(\w+)\.png$/.exec(path);
+ if (m && PICS[m[1]]) { if (m[1] === 'slow') await held; return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const ORDER = ['t1', 'slow', 't2', 'w1', 'sq', 't3', 't4'];
+const seeded = ORDER.map((n, i) => post('picture ' + n + ' ' + U(n), 100 + i * 100));
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const name = (k) => k.split('|')[1].split('/').pop().replace('.png', '');
+const carousel = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' '));
+const cur = () => page.evaluate(() => { const s = document.querySelector('.mp-slide.mp-cur'); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : null; });
+const resting = (n) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 2 && Math.abs(r.left) < 2; }); return !!s && s.getAttribute('data-k').endsWith('/' + n + '.png'); }, n, 5000);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 7, null, 20000));
+ await waitFor(() => !!document.querySelector('.notes-feed img.note-img'), null, 10000);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(400);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide'), null, 4000) && await resting('t1'), await cur());
+
+ console.log('\n[a picture joins once decoded]');
+ check('the pictures decoded join: t2, t3, t4 and the square', await waitFor(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= 5, null, 8000), await carousel());
+ check('the one still on its way waits out of the carousel', !(await carousel()).includes('slow'), await carousel());
+ check('the wide one is out for its shape', !(await carousel()).includes('w1'), await carousel());
+ check('with fewer than ten ready and one still warming, no counter shows', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'hidden'));
+ await page.keyboard.press('ArrowDown');
+ check('down from t1 passes over it, to t2', await resting('t2'), await cur());
+ release();
+ check('once it has come, it joins, above the one on screen', await waitFor(() => /t1 slow t2/.test([...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' ')), null, 8000), await carousel());
+ check('…without moving it', await resting('t2'), await cur());
+ check('everything in, the counter shows: 3 / 6', await waitFor(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible' && document.querySelector('.mp-position').textContent === '3 / 6', null, 3000), await page.evaluate(() => document.querySelector('.mp-position').textContent));
+ await page.keyboard.press('ArrowUp');
+ check('up lands on it', await resting('slow'), await cur());
+
+ console.log('\n[the shape toggle in the picture pager]');
+ const label = await page.evaluate(() => document.querySelector('.media-pager .mp-fit')?.textContent);
+ check('the top bar has the toggle, saying tall only', label === 'Tall only', String(label));
+ check('…as tall as the close button', await page.evaluate(() => document.querySelector('.mp-fit').offsetHeight === document.querySelector('.mp-x').offsetHeight));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide picture joins', await waitFor(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].some((g) => /w1\.png$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))), null, 6000), await carousel());
+ check('…and the toggle says all shapes', (await page.evaluate(() => document.querySelector('.mp-fit').textContent)) === 'All shapes');
+ check('…the picture on screen stayed put', await resting('slow'), await cur());
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: the wide one is out', await waitFor(() => !document.querySelector('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off) .mp-slide[data-k$="/w1.png"]'), null, 3000), await carousel());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { release(); await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the picture pager: decoded pictures only, the screen\'s way round, with the toggle' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js
index 0b8bf6f..2ed1260 100644
--- a/tools/post-edit-test.js
+++ b/tools/post-edit-test.js
@@ -160,6 +160,7 @@ try {
await openFeed();
check('the feed is back', await waitText('a post a stranger tried to delete', 15000));
check('my deleted post stays gone', !(await has('my own post')));
+ check('...with no "was deleted" row in its place', !(await has('deleted by its author')));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed');
diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js
index 85aee86..2a78598 100644
--- a/tools/profile-pager-test.js
+++ b/tools/profile-pager-test.js
@@ -36,6 +36,7 @@ const seeded = [
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
@@ -104,18 +105,15 @@ try {
await page.keyboard.press('ArrowDown');
check('down again: her last', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowDown'); await sleep(600);
- check('past her last wraps to her first post', await resting('a2.png'), await curPic());
- check('counter counts the first post once', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
+ check('past her last it stays on her last (no going round)', await resting('d.png'), await curPic());
+ check('counter still at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
check('no loading slide', await page.$('.mp-tail') === null);
- await page.keyboard.press('ArrowUp'); await resting('d.png');
- check('up from first wraps to last', await curPic() === 'd.png');
- check('counter at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
await page.touchscreen.touchStart(195, 650);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); }
- await page.touchscreen.touchEnd();
- check('touch swipe wraps to first post', await resting('a2.png'), await curPic());
- await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true })));
- check('wheel wraps back to last post', await resting('d.png'), await curPic());
+ await page.touchscreen.touchEnd(); await sleep(600);
+ check('a touch swipe on from her last stays there', await resting('d.png'), await curPic());
+ await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: 3, deltaMode: 1, bubbles: true, cancelable: true }))); await sleep(600);
+ check('…and so does the wheel', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowUp');
check('up: her post before', await resting('b.png'), await curPic());
await page.keyboard.press('Escape');
@@ -125,8 +123,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/profile-publish-race-test.js b/tools/profile-publish-race-test.js
new file mode 100644
index 0000000..becf375
--- /dev/null
+++ b/tools/profile-publish-race-test.js
@@ -0,0 +1,111 @@
+// Two kind-0 publishes at once used to clobber each other: both read the
+// same base off the relays, the later one landed without the earlier one's
+// fields. The migration did exactly that — the rename repointed lud16/nip05
+// to the new name while the legacy-avatar import republished the old base
+// nine seconds later, so a user's published lightning address was the
+// placeholder name their rename had just released (Jarryd, 2026-10-24).
+// Now kind-0 publishes go one at a time and merge over what was last sent.
+// Run: bun tools/profile-publish-race-test.js
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 160) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+
+// One in-memory relay behind every socket: EVENT stores, REQ answers with
+// what matches (newest first) and EOSE. Enough for a kind-0 round trip.
+const store = [];
+const published = [];
+class FakeSocket {
+ constructor(url) {
+ this.url = url; this.readyState = 0;
+ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0);
+ }
+ reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); }
+ send(str) {
+ let m; try { m = JSON.parse(str); } catch { return; }
+ if (m[0] === 'EVENT') { store.push(m[1]); published.push(m[1]); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ const hit = store.filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))));
+ hit.sort((a, b) => b.created_at - a.created_at);
+ for (const e of hit) this.reply(['EVENT', sub, e]);
+ this.reply(['EOSE', sub]);
+ }
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({}); }
+}
+globalThis.WebSocket = FakeSocket;
+globalThis.Image = class { set src(v) { this._src = v; } };
+// the legacy account the migration brings along
+globalThis.fetch = async (url) => {
+ if (/coinos\.io\/api\/users\//.test(String(url))) return { ok: true, json: async () => ({ profile: 'face', banner: 'cover' }) };
+ throw new Error('offline');
+};
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const sk = generateSecretKey();
+const pk = getPublicKey(sk);
+const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+let now = Math.floor(Date.now() / 1000) - 100;
+const seed = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk);
+store.push(seed);
+
+const walletSk = generateSecretKey();
+const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ loadFeatureState: () => null, saveFeatureState: () => {},
+ _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
+};
+let namesAddr = null; // what the registrar says our address is
+const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null,
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : name === 'namesAddress' ? namesAddr : null),
+});
+
+console.log('[the migration: rename and avatar import fired together]');
+await Promise.all([
+ feature.addressRenamed('[email protected]', '[email protected]'),
+ feature.adoptLegacyProfile('jarryd'),
+]);
+const newest = store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const final = JSON.parse(newest.content);
+const sent = [...new Map(published.filter((e) => e.kind === 0).map((e) => [e.id, e])).values()];
+check('two kind 0s went out, one after the other', sent.length === 2 && sent[1].created_at > sent[0].created_at, sent.map((e) => e.created_at + ' ' + JSON.parse(e.content).lud16 + ' ' + (JSON.parse(e.content).picture ? 'face' : '-')).join(' → '));
+check('the newest kind 0 carries the new address', final.lud16 === '[email protected]' && final.nip05 === '[email protected]', JSON.stringify(final));
+check('...and the legacy face and cover', /face\.webp$/.test(final.picture || '') && /cover\.webp$/.test(final.banner || ''), final.picture + ' ' + final.banner);
+check('...and the name it had', final.name === 'Jarryd');
+
+console.log('[a second rename right behind, before the relays answer]');
+published.length = 0;
+await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), feature.publishProfile({ about: 'hi' })]);
+const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content);
+check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last));
+
+console.log('[Save in the editor repoints a dead same-domain address]');
+// The heal never ran (an Amber user: no background signatures), so the kind 0
+// still names the released placeholder. The domain says jar is dead, other
+// is alive.
+store.push(finalizeEvent({ kind: 0, created_at: now + 1000, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk));
+namesAddr = '[email protected]';
+globalThis.fetch = async (url) => /lnurlp\/jar$/.test(url)
+ ? { ok: false, status: 500, json: async () => null }
+ : { ok: true, status: 200, json: async () => ({ tag: 'payRequest', callback: 'https://x/cb' }) };
+published.length = 0;
+await feature.saveProfileDraft({ uname: 'jarryd', name: 'Jarryd' });
+const saved = JSON.parse(published.filter((e) => e.kind === 0).pop().content);
+check('the dead placeholder moves to the claimed address', saved.lud16 === '[email protected]', JSON.stringify(saved));
+check('a same-domain address that still answers is left alone', saved.nip05 === '[email protected]', saved.nip05);
+
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns');
+process.exit(fails ? 1 : 0);
diff --git a/tools/push-resubscribe-test.js b/tools/push-resubscribe-test.js
new file mode 100644
index 0000000..19998b2
--- /dev/null
+++ b/tools/push-resubscribe-test.js
@@ -0,0 +1,103 @@
+// iOS drops a web push subscription after three pushes that showed no
+// notification, and nothing used to make a new one: the wallet's background
+// switch stayed on, getSubscription() came back empty, and the notifier kept
+// a dead endpoint, so NWC requests stopped waking the closed app until the
+// user toggled it off and on (reported by PullCap). Opening the app, or
+// coming back to it, must subscribe again and tell the notifier.
+//
+// Run: bun tools/push-resubscribe-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.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: 5263, fetch: () => 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 KEY = 'BP' + 'A'.repeat(85); // any 65-byte key, base64url
+await page.evaluateOnNewDocument((KEY) => {
+ const bytes = () => Uint8Array.from(atob(KEY.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
+ window.__subs = 0; window.__regs = [];
+ let sub = null; // iOS has already dropped it
+ const make = () => ({
+ endpoint: 'https://web.push.apple.com/sub' + window.__subs,
+ options: { applicationServerKey: bytes().buffer },
+ toJSON() { return { endpoint: this.endpoint, keys: { p256dh: 'x', auth: 'y' } }; },
+ unsubscribe: () => { sub = null; return Promise.resolve(true); },
+ });
+ window.__drop = () => { sub = null; };
+ const reg = {
+ pushManager: {
+ getSubscription: () => Promise.resolve(sub),
+ subscribe: () => { window.__subs++; sub = make(); return Promise.resolve(sub); },
+ },
+ showNotification: () => Promise.resolve(),
+ };
+ Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve(reg) });
+ Notification.requestPermission = () => Promise.resolve('granted');
+ Object.defineProperty(Notification, 'permission', { get: () => 'granted' });
+ const f = window.fetch;
+ window.fetch = async (u, o) => {
+ const url = String(u);
+ if (url.startsWith('https://nwcpush.coinos.io/vapid')) return new Response(JSON.stringify({ publicKey: KEY }));
+ if (url.startsWith('https://nwcpush.coinos.io/register')) {
+ const b = JSON.parse(o.body);
+ if (b.servicePubkeys) window.__regs.push(b.subscription.endpoint);
+ return new Response('{"ok":true}');
+ }
+ return f(u, o);
+ };
+}, KEY);
+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; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5263/', { 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(500);
+ await click('to import'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+
+ // a wallet answering in the background, whose subscription iOS took away
+ await page.evaluate((k) => localStorage.setItem(k + ':nwc', JSON.stringify({
+ conns: [{ id: 'x', name: 'test', servicePk: 'a'.repeat(64), sk: 'b'.repeat(64), maxSat: 1000, budget: {} }],
+ background: true,
+ })), base);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(3000);
+ let s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('opening the app subscribes again', s.subs >= 1, `${s.subs} subscribe()`);
+ check('...and registers the new endpoint for the connection', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' '));
+
+ // dropped again while the app sat in the background; it resumes, no reload
+ await page.evaluate(() => { window.__drop(); window.__regs.length = 0; });
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await sleep(3000);
+ const before = s.subs;
+ s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('coming back to the app subscribes again', s.subs > before, `${s.subs} subscribe()`);
+ check('...and registers it', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' '));
+
+ // a live subscription is left alone
+ await page.evaluate(() => { window.__regs.length = 0; document.dispatchEvent(new Event('visibilitychange')); });
+ await sleep(3000);
+ const again = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('a subscription still there is reused', again.subs === s.subs && again.regs.length >= 1);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a dropped push subscription comes back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js
new file mode 100644
index 0000000..41984a2
--- /dev/null
+++ b/tools/quoted-clip-fullscreen-test.js
@@ -0,0 +1,92 @@
+// A clip in the post a reply answers (the "Replying to" card in the
+// Conversations view) isn't one of the feed's own, so the media pager can't
+// take it: a tap on it, or the player's own full-screen button, puts it full
+// screen the browser's way. Both used to do nothing at all — the full-screen
+// handler took the clip back out of full screen for a pager that then
+// wouldn't open.
+// Run: bun tools/quoted-clip-fullscreen-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 PORT = 5303;
+const CLIP = '/tmp/coinos-quoted-clip.webm';
+if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x568:rate=10', '-t', '6', '-c:v', 'libvpx', '-b:v', '200k', CLIP]);
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const B1 = post(`have you seen this http://localhost:${PORT}/clip.webm`, 400, SKB);
+const R1 = post('alice answers the clip', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: PORT, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/clip.webm') return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } });
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+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, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn)) return true; await sleep(100); } return false; };
+const SEL = '.note-context .quote-card video.note-video';
+const state = () => page.evaluate((sel) => {
+ const v = document.querySelector(sel), fs = document.fullscreenElement;
+ return { fs: fs ? (fs === v ? 'video' : fs.tagName + '.' + fs.className) : null, pager: !!document.querySelector('.media-pager'), thread: !!document.querySelector('.thread-card') };
+}, SEL);
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(`http://localhost:${PORT}/`, { 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', t: Date.now() }, [bob]: { name: 'Bob', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, [B1], [R1]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await waitFor(() => !!document.querySelector('.media-mode[data-mode="replies"]'), 20000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('the reply shows the clip in its "Replying to" card', await waitFor(() => !!document.querySelector('.note-context .quote-card video.note-video'), 20000));
+ await page.evaluate((sel) => document.querySelector(sel).scrollIntoView({ block: 'center' }), SEL);
+ await sleep(800);
+
+ console.log('\n[a tap on the picture]');
+ const box = await (await page.$(SEL)).boundingBox();
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [box.x + box.width / 2, box.y + box.height * 0.25]);
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button
+ check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state()));
+ await sleep(600);
+ check('...and stays there (not taken back out)', (await state()).fs === 'video', JSON.stringify(await state()));
+ check('...and no thread opened under it', !(await state()).thread);
+ await page.evaluate(() => document.exitFullscreen());
+ await waitFor(() => !document.fullscreenElement, 3000);
+
+ console.log('\n[the player\'s full-screen button]');
+ await page.evaluate((sel) => document.querySelector(sel).requestFullscreen(), SEL); // as the ⛶ on its bar does
+ await sleep(800);
+ check('full screen, and it stays', (await state()).fs === 'video', JSON.stringify(await state()));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ a quoted post\'s clip goes full screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/receive-batch-fold-test.js b/tools/receive-batch-fold-test.js
new file mode 100644
index 0000000..8e38265
--- /dev/null
+++ b/tools/receive-batch-fold-test.js
@@ -0,0 +1,20 @@
+// One payment delivered as several coins — a gift whose 886 sats sat in two
+// (595 + 291, dust padding) swept in one claim — is one Received row.
+// sameDelivery decides which receive rows fold; nothing else does.
+// Run: bun tools/receive-batch-fold-test.js
+import { sameDelivery } from '../src/ark/manager.js';
+let ok = true;
+const check = (n, c) => { console.log(` ${c ? '✓' : '✗'} ${n}`); if (!c) ok = false; };
+const T = 1_790_000_000_000;
+const rcv = (id, sat, ts, extra = {}) => ({ id, type: 'receive', status: 'complete', amountSat: sat, ts, vtxoId: 'v-' + id, ...extra });
+check('two coins of one mailbox message fold', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7' })));
+check('...even when their instants differ (live, no checkpoint time)', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T + 40, { batch: '7' })));
+check('two messages stay two rows, same instant or not', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '8' })));
+check('a stamped row never folds an unstamped one', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T)));
+check('older rows (no stamp) fold on the same delivery instant', sameDelivery(rcv('a', 595, T), rcv('b', 291, T)));
+check('...not a millisecond apart', !sameDelivery(rcv('a', 595, T), rcv('b', 291, T + 1)));
+check('a ⚡ receive is its own payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', viaLn: true })));
+check('a rejected coin is not part of the payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', status: 'rejected' })));
+check('only receives fold', !sameDelivery({ ...rcv('a', 595, T), type: 'send' }, rcv('b', 291, T)));
+console.log(ok ? '\n✅ one delivery, one row' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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/relay-link-test.js b/tools/relay-link-test.js
new file mode 100644
index 0000000..52984ad
--- /dev/null
+++ b/tools/relay-link-test.js
@@ -0,0 +1,66 @@
+// A relay named in a post (wss://…) is a link: tapping it opens the raw
+// firehose of that relay (/feed/all?r=…), read from that relay alone.
+// Run: bun tools/relay-link-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { buildHtml, buildVerifyWorker } from '../build.js';
+
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'I run a good relay to read directly:\n\nwss://theforest.nostr1.com.\n\nAnyone can read from it.' }, sk);
+const forest = finalizeEvent({ kind: 1, created_at: now - 5, tags: [], content: 'Hello from the forest relay, one of its own posts.' }, generateSecretKey());
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Relay Admin' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const worker = await buildVerifyWorker({ minify: true });
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, notes: [note], profiles: [profile] });
+ if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } });
+ if (path.startsWith('/punks')) return new Response(null, { 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 check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()));
+ await page.evaluateOnNewDocument((forest) => {
+ window.asked = [];
+ class FakeSocket extends EventTarget {
+ static OPEN = 1; static CLOSED = 3;
+ readyState = 0;
+ constructor(url) { super(); this.url = url; setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); }
+ emit(m) { const e = new MessageEvent('message', { data: JSON.stringify(m) }); this.onmessage?.(e); this.dispatchEvent(e); }
+ send(data) {
+ const m = JSON.parse(data);
+ if (m[0] !== 'REQ') return;
+ window.asked.push({ url: this.url, filter: m[2] });
+ setTimeout(() => {
+ if (/theforest/.test(this.url) && m[2].kinds?.includes(1) && !m[2].ids && !m[2].authors) this.emit(['EVENT', m[1], forest]);
+ this.emit(['EOSE', m[1]]);
+ }, 0);
+ }
+ close() { this.readyState = 3; this.onclose?.({}); }
+ }
+ window.WebSocket = FakeSocket;
+ }, forest);
+ await page.goto(server.url.origin + '/feed');
+ await page.waitForFunction(() => document.body.innerText.includes('I run a good relay'), { timeout: 5000 });
+ const link = await page.$$eval('.notes-feed a', (as) => as.filter((a) => /theforest/.test(a.textContent)).map((a) => ({ text: a.textContent, href: a.getAttribute('href') })));
+ check('the relay in the post is a link', link.length === 1, JSON.stringify(link));
+ check('...without the full stop after it', link[0].text === 'wss://theforest.nostr1.com' && /theforest\.nostr1\.com\./.test(await page.evaluate(() => document.querySelector('.notes-feed').innerText)));
+ check('...to that relay\'s firehose', link[0].href === '/feed/all?r=' + encodeURIComponent('wss://theforest.nostr1.com'), link[0].href);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].find((a) => /theforest/.test(a.textContent)).click());
+ check('tapping it shows the relay\'s own posts', await page.waitForFunction(() => document.body.innerText.includes('Hello from the forest relay'), { timeout: 8000 }).then(() => true, () => false));
+ const forestAsks = await page.evaluate(() => window.asked.filter((a) => /theforest/.test(a.url) && a.filter.kinds?.includes(1) && !a.filter.authors && !a.filter.ids));
+ check('...asked of that relay, for everything on it', forestAsks.length > 0);
+ check('...and the address bar says so', decodeURIComponent(await page.evaluate(() => location.pathname + location.search)) === '/feed/all?r=wss://theforest.nostr1.com', await page.evaluate(() => location.href));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ relay links open the relay');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js
new file mode 100644
index 0000000..3b992fb
--- /dev/null
+++ b/tools/resume-wake-test.js
@@ -0,0 +1,111 @@
+// Back from the background on a phone, the app's relay sockets read OPEN
+// and are dead (the tab was frozen; the network forgot them), and the
+// catch-up it sends on them finds nothing. The library noticed a dead one
+// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a
+// Following feed stuck at the morning's posts for a minute or more after
+// opening the app in the evening ("no new notes since 11:35?", Adam,
+// 2026-11-01). On resume every open socket is now asked one tiny REQ; a
+// silent one is closed and redialed at once, its subscriptions refired, and
+// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-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 html = await buildHtml({ minify: true, pwa: false });
+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 stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)];
+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.since || e.created_at >= f.since);
+// one relay; a socket marked dead keeps its connection and answers nothing,
+// the way a phone's frozen sockets do once the network has forgotten them
+const sockets = new Set(); let opened = 0, closed = 0;
+const server = Bun.serve({ port: 0, fetch: (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+}, websocket: {
+ open(ws) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; },
+ message(ws, raw) {
+ if (ws.data.dead) return;
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
+ if (m[0] === 'REQ') {
+ const s = { id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]);
+ },
+ close(ws) { sockets.delete(ws); closed++; },
+} });
+const PORT = server.port;
+const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]);
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 390, height: 844 });
+await page.evaluateOnNewDocument((port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+}, PORT);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const warns = [];
+page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.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 waitFor = async (fn, arg, ms = 10000) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; };
+const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40)));
+
+try {
+ await page.goto(`http://localhost:${PORT}/`, { 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: 'Early Bird', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, stored]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | '));
+ check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets');
+ const openedBefore = opened;
+
+ console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]');
+ for (const s of sockets) s.data.dead = true;
+ const fresh = post('evening news', 0);
+ stored.push(fresh);
+ await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base);
+ await sleep(300);
+
+ console.log('\n[back on screen]');
+ const t0 = Date.now();
+ await page.evaluate(() => window.dispatchEvent(new Event('focus')));
+ const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })();
+ check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s');
+ const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000);
+ check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | '));
+ check('the dead sockets were let go', closed >= 1, closed + ' closed');
+ check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs');
+ 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✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/safe-boot-test.js b/tools/safe-boot-test.js
new file mode 100644
index 0000000..a23c067
--- /dev/null
+++ b/tools/safe-boot-test.js
@@ -0,0 +1,77 @@
+// The crash-loop guard: the inline BOOT_GUARD counts page starts, the app
+// clears the count once it has lived or leaves cleanly, and a third start
+// in a row opens in safe mode — a notice naming the last boot phase, no
+// automatic Ark connection, and "Open normally" back to a plain boot.
+// Run: bun tools/safe-boot-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { BOOT_GUARD } from '../build.js';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'safe-boot-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.sbApp = { ui, wallet, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.sbArk = { get ark() { return ark; }, get connecting() { return !!arkConnectPromise; }, get lastAutoInit() { return lastAutoInit; } };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div>${BOOT_GUARD}<script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ if (!localStorage.getItem('sb-seeded')) {
+ localStorage.setItem('sb-seeded', '1');
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+ // two starts already, neither cleared: the last one died merging the worker's state
+ localStorage.setItem('coinos-boot', '2');
+ localStorage.setItem('coinos-boot-phase', 'ark-merge');
+ }
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.sbApp?.ready);
+ const guard = await page.evaluate(() => ({ safe: window.__safeBoot, phase: window.__bootPhase, count: localStorage.getItem('coinos-boot') }));
+ check('the third start in a row opens in safe mode, knowing the phase the last one reached', guard.safe === 3 && guard.phase === 'ark-merge' && guard.count === '3', JSON.stringify(guard));
+ await page.evaluate(async (mn) => {
+ await sbApp.enterWallet(mn, '', { fresh: true, generated: true });
+ sbApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ sbApp.ui.tab = 'wallet'; sbApp.render();
+ }, generateMnemonic(wordlist));
+ await new Promise((r) => setTimeout(r, 1500));
+ const notice = await page.evaluate(() => { const n = [...document.querySelectorAll('.notice')].find((x) => /safe mode/i.test(x.textContent)); return n ? n.textContent : ''; });
+ check('the wallet home says so and names the phase', /safe mode/i.test(notice) && /ark-merge/.test(notice) && /2 times/.test(notice), notice.slice(0, 120));
+ const arkState = await page.evaluate(() => ({ ark: !!sbArk.ark, connecting: sbArk.connecting, auto: sbArk.lastAutoInit }));
+ check('nothing dialled the Ark server on its own', !arkState.ark && !arkState.connecting && !arkState.auto, JSON.stringify(arkState));
+ // a clean leave clears the count
+ await page.evaluate(() => dispatchEvent(new Event('pagehide')));
+ check('leaving the page cleanly clears the count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null);
+ // "Open normally": the count is cleared and the page reloads into a plain boot
+ await page.evaluate(() => localStorage.setItem('coinos-boot', '3'));
+ await Promise.all([
+ page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
+ page.evaluate(() => { const b = [...document.querySelectorAll('.notice button')].find((x) => /open normally/i.test(x.textContent)); b.click(); }),
+ ]);
+ await page.waitForFunction(() => window.sbApp?.ready);
+ const after = await page.evaluate(() => ({ safe: window.__safeBoot, count: localStorage.getItem('coinos-boot'), notice: [...document.querySelectorAll('.notice')].some((x) => /safe mode/i.test(x.textContent)) }));
+ check('"Open normally" reloads into a plain boot (one start counted, no notice)', after.safe === undefined && after.count === '1' && !after.notice, JSON.stringify(after));
+ await new Promise((r) => setTimeout(r, 8600));
+ check('a boot that lives eight seconds clears its count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null);
+ check('no page errors', errors.length === 0, errors.join(' | ').slice(0, 200));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ safe mode after a crash loop' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/scheduled-post-test.js b/tools/scheduled-post-test.js
new file mode 100644
index 0000000..546537c
--- /dev/null
+++ b/tools/scheduled-post-test.js
@@ -0,0 +1,99 @@
+// Scheduled posts: the composer's clock button picks a time; Schedule signs
+// the note with that created_at and hands it to the notifier (POST
+// nwcpush /schedule), nothing goes to a relay, the composer closes and the
+// post waits in a "Scheduled" list, where Cancel sends a kind 5 for it to
+// /schedule/cancel. The notifier is stubbed.
+// Run: bun tools/scheduled-post-test.js
+import puppeteer from './puppeteer.js';
+import { verifyEvent } 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 html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5316, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); 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();
+await page.evaluateOnNewDocument(() => {
+ window.__published = [];
+ 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') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const held = new Map();
+const calls = [];
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.host !== 'nwcpush.coinos.io' || !u.pathname.startsWith('/schedule')) return r.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' };
+ if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '' });
+ const body = r.postData() ? JSON.parse(r.postData()) : null;
+ calls.push({ path: u.pathname, body });
+ let res = { ok: true };
+ if (u.pathname === '/schedule') held.set(body.event.id, body.event);
+ if (u.pathname === '/schedule/cancel') { let n = 0; for (const [k, id] of body.event.tags) if (k === 'e' && held.delete(id)) n++; res = { ok: true, cancelled: n }; }
+ if (u.pathname === '/schedule/status') res = Object.fromEntries(u.searchParams.get('ids').split(',').map((id) => [id, held.has(id) ? 'pending' : 'gone']));
+ r.respond({ status: 200, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(res) });
+});
+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, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5316/', { 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(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.post-input'), 8000);
+ await page.focus('.post-input');
+ await page.keyboard.type('later, #coinos', { delay: 10 });
+ await page.evaluate(() => document.querySelector('.post-actions button[aria-label="Schedule for later"]').click());
+ check('the clock button shows a time field, about an hour ahead', await waitFor(() => !!document.querySelector('.sched-pick input[type=datetime-local]'), 5000));
+ const shown = await page.$eval('.sched-pick input', (e) => e.value);
+ const shownAt = new Date(shown).getTime();
+ check('...on a quarter hour', shownAt - Date.now() > 3500_000 && shownAt - Date.now() < 4500_000 && new Date(shownAt).getMinutes() % 15 === 0, shown);
+ check('the main button reads Schedule', await page.evaluate(() => document.querySelector('.post-actions .btn-primary').textContent.trim() === 'Schedule'));
+ // two hours ahead, typed as the user would
+ const want = new Date(Date.now() + 2 * 3600_000); want.setSeconds(0, 0);
+ const local = new Date(want.getTime() - want.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
+ await page.evaluate((v) => { const i = document.querySelector('.sched-pick input'); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }, local);
+ await page.evaluate(() => { window.__published = []; document.querySelector('.post-actions .btn-primary').click(); });
+ await waitFor(() => !document.querySelector('.post-input'), 8000);
+ const sc = calls.find((c) => c.path === '/schedule');
+ const ev = sc && sc.body.event;
+ check('the notifier gets a signed kind 1 dated at the chosen time', !!ev && ev.kind === 1 && verifyEvent(ev) && ev.created_at === Math.floor(want.getTime() / 1000), ev && new Date(ev.created_at * 1000).toISOString());
+ check('...with its tags (t coinos, client) and the relays to send it to', !!ev && ev.tags.some((x) => x[0] === 't' && x[1] === 'coinos') && ev.tags.some((x) => x[0] === 'client') && sc.body.relays.length > 0, JSON.stringify(sc?.body.relays));
+ check('nothing went to a relay', !(await page.evaluate(() => window.__published.some((e) => e.kind === 1))));
+ check('the composer closed and the draft is cleared', await page.evaluate(() => !document.querySelector('.post-input')));
+ check('the post is not in the feed yet', await page.evaluate(() => !document.body.innerText.includes('later, #coinos') || !!document.querySelector('.sched-list')) && !(await page.evaluate(() => [...document.querySelectorAll('.note-text')].some((n) => n.textContent.includes('later, #coinos')))));
+
+ // the list shows with the composer open and on your profile
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('the composer lists it under Scheduled (1)', await waitFor(() => /Scheduled \(1\)/.test(document.querySelector('.sched-list')?.innerText || ''), 5000));
+ check('...with its words', await page.evaluate(() => document.querySelector('.sched-item').innerText.includes('later, #coinos')));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('it survives a reload (and the status check keeps it)', await waitFor(() => !!document.querySelector('.sched-item'), 8000) && calls.some((c) => c.path === '/schedule/status'));
+ await page.evaluate(() => [...document.querySelectorAll('.sched-item button')].find((b) => b.textContent.trim() === 'Cancel').click());
+ await waitFor(() => !document.querySelector('.sched-item'), 8000);
+ const cc = calls.find((c) => c.path === '/schedule/cancel');
+ check('Cancel sends a kind 5 naming it, signed by the author', !!cc && cc.body.event.kind === 5 && verifyEvent(cc.body.event) && cc.body.event.pubkey === ev.pubkey && cc.body.event.tags.some((x) => x[0] === 'e' && x[1] === ev.id));
+ check('...and it leaves the list', await page.evaluate(() => !document.querySelector('.sched-list')));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { check('ran', false, e.stack); }
+await browser.close(); server.stop();
+console.log(ok ? '\nALL PASS' : '\nFAILED');
+process.exit(ok ? 0 : 1);
diff --git a/tools/signer-resume-test.js b/tools/signer-resume-test.js
new file mode 100644
index 0000000..c35b655
--- /dev/null
+++ b/tools/signer-resume-test.js
@@ -0,0 +1,140 @@
+// A remote signer (Amethyst) that wasn't heard the moment the app needed it
+// — the tab was frozen, a socket cut, the signer app asleep — put up
+// "Reconnect your signer" and then never tried the stored session again, so
+// every time meant a brand-new connection (Adam's phone, 2026-10-29: the
+// stored session answered in 0.7 s the moment anyone asked). Now the screen
+// keeps trying the stored session and closes itself once the signer answers.
+// The remote signer is played by this script over a shimmed relay.
+// Run: bun tools/signer-resume-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import * as nip44 from 'nostr-tools/nip44';
+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(); const PK = getPublicKey(SK); // the account, held by the remote signer
+const CLIENT_SK = generateSecretKey(); const CLIENT = getPublicKey(CLIENT_SK); // the client key it authorised long ago
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5319, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p === '/nip46.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); 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();
+await page.setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36');
+await page.evaluateOnNewDocument((fx) => {
+ window.__amberUrls = [];
+ window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); };
+ window.__published = [];
+ const subs = [];
+ const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#p'] || ev.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1])))
+ && (!f.authors || f.authors.includes(ev.pubkey)) && (!f.since || ev.created_at >= f.since);
+ // the test pushes the signer's answers through here, to every live sub that wants them
+ window.__inject = (ev) => { for (const s of subs) if (s.fs.some((f) => fits(ev, f))) s.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', s.id, ev]) })); };
+ 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') {
+ const fs = m.slice(2);
+ for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1);
+ subs.push({ ws, id: m[1], fs });
+ const evs = fx.filter((ev) => fs.some((f) => fits(ev, f)));
+ setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'CLOSE') { for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); return; }
+ if (m[0] === 'EVENT') {
+ window.__published.push(m[1]);
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
+ 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 text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+
+// The remote signer: answers the connect, then get_public_key / nip44_decrypt.
+let client = CLIENT, awake = false, handled = new Set(), decrypts = 0;
+const signerLoop = async () => {
+ if (!awake) return;
+ const pub = await page.evaluate(() => window.__published.filter((e) => e.kind === 24133));
+ for (const ev of pub) {
+ if (handled.has(ev.id) || !client || ev.pubkey !== client) continue;
+ handled.add(ev.id);
+ const ck = nip44.getConversationKey(SK, ev.pubkey);
+ const req = JSON.parse(nip44.decrypt(ev.content, ck));
+ let result;
+ if (req.method === 'get_public_key') result = PK;
+ else if (req.method === 'nip44_decrypt') { decrypts++; result = nip44.decrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); }
+ else if (req.method === 'nip44_encrypt') result = nip44.encrypt(req.params[1], nip44.getConversationKey(SK, req.params[0]));
+ else if (req.method === 'sign_event') { const u = JSON.parse(req.params[0]); result = JSON.stringify(finalizeEvent(u, SK)); }
+ else if (req.method === 'ping') result = 'pong';
+ else if (req.method === 'connect') result = 'ack';
+ const resp = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', ev.pubkey]], content: nip44.encrypt(JSON.stringify({ id: req.id, result }), ck) }, SK);
+ await page.evaluate((e) => window.__inject(e), resp);
+ }
+};
+
+
+const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const pump = async (ms) => { for (let i = 0; i < ms / 250; i++) { await signerLoop(); await sleep(250); } };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5319/', { 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 local = Array.from(CLIENT_SK, (b) => b.toString(16).padStart(2, '0')).join('');
+ await page.evaluate(([k, pk, local]) => {
+ localStorage.setItem(k + ':nostrlogin', JSON.stringify({ pubkey: pk, linked: Date.now(), via: 'bunker',
+ session: { local, bp: { pubkey: pk, relays: ['wss://relay.coinos.io'], secret: null } } }));
+ }, [base, PK, local]);
+
+ console.log('\n[the signer is asleep when the app needs it]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => !!document.querySelector('.feed-post-fab'));
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ await waitFor(() => !!document.querySelector('.post-input'), 8000);
+ await page.focus('.post-input');
+ await page.keyboard.type('hello', { delay: 10 });
+ await page.evaluate(() => document.querySelector('.post-actions .btn-primary').click());
+ check('the reconnect screen comes up', await waitText('reconnect your signer', 45000));
+ await sleep(16000); // let the screen's first try run out unanswered
+ check('...and stays while the signer is silent', (await text()).toLowerCase().includes('reconnect your signer'));
+
+ console.log('\n[the signer wakes up]');
+ awake = true;
+ const t0 = Date.now();
+ let gone = false;
+ for (let i = 0; i < 160 && !gone; i++) { await signerLoop(); gone = !(await text()).toLowerCase().includes('reconnect your signer'); if (!gone) await sleep(250); }
+ check('the screen closes by itself, no new connection', gone, gone ? `${Math.round((Date.now() - t0) / 1000)} s after the signer answered` : 'still up after 40 s');
+ const st = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':nostrlogin') || '{}'), base);
+ check('the stored session is the same one', st.session && st.session.local === local);
+ check('no nostrconnect link was needed', !(await page.evaluate((pk) => window.__published.some((e) => e.kind === 24133 && e.pubkey !== pk), CLIENT)));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a stored remote-signer session comes back by itself' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js
new file mode 100644
index 0000000..cf4a7bf
--- /dev/null
+++ b/tools/sw-push-quota.test.js
@@ -0,0 +1,89 @@
+// WebKit drops a push subscription after three pushes that show no
+// notification, so on an Apple endpoint every path through the worker's push
+// handler must end in one. Run: bun test tools/sw-push-quota.test.js
+import { test, expect, mock } from 'bun:test';
+
+let respond = async () => true;
+mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] }));
+mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() }));
+mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) }));
+mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} }));
+mock.module('../src/dm-inbox.js', () => ({
+ allInboxes: async () => [{ muted: ['m'.repeat(64)] }],
+ classifyDm: async () => ({ own: true }), shouldNotifyDm: () => false, classifyCall: async () => null,
+}));
+
+const listeners = {};
+let endpoint = 'https://web.push.apple.com/abc';
+let windows = [];
+const shown = [], closed = [];
+globalThis.self = {
+ location: { hostname: 'v3.coinos.io', origin: 'https://v3.coinos.io' },
+ addEventListener: (t, f) => { listeners[t] = f; },
+ clients: { matchAll: async () => windows },
+ registration: {
+ pushManager: { getSubscription: async () => ({ endpoint }) },
+ showNotification: async (title, o) => { shown.push({ title, ...o }); },
+ getNotifications: async ({ tag }) => shown.filter((n) => n.tag === tag).map((n) => ({ close: () => closed.push(n) })),
+ },
+};
+await import('../src/sw-nwc.js');
+
+async function push(data) {
+ shown.length = 0; closed.length = 0;
+ let done;
+ listeners.push({ data: { json: () => data }, waitUntil: (p) => { done = p; } });
+ await done;
+ return shown.map((n) => n.title);
+}
+const nwc = { type: 'nwc', servicePubkey: 'a'.repeat(64), event: { id: 'e'.repeat(64), kind: 23194 } };
+const hidden = { visibilityState: 'hidden', postMessage: () => {} };
+const visible = { visibilityState: 'visible', postMessage: () => {} };
+
+test('apple: every quiet path shows the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => true; // a lookup answered, or another device took it
+ expect(await push(nwc)).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'dm', wrap: {} })).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'reaction', from: 'm'.repeat(64) })).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'call', wrapId: 'x' })).toEqual(['Coinos']);
+ expect(await push({ type: 'weird' })).toEqual(['Coinos']);
+ expect(closed.length).toBe(0);
+});
+
+test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] });
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
+});
+
+test('apple: an app on screen gets the stand-in closed at once', async () => {
+ windows = [visible];
+ expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']);
+ expect(closed.length).toBe(1);
+});
+
+test('apple: a suspended background app does not swallow the request', async () => {
+ windows = [hidden];
+ let asked = 0;
+ respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; };
+ // the stand-in goes up first and the outcome replaces it (91dbee4)
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
+ expect(asked).toBe(1);
+});
+
+test('elsewhere: quiet stays quiet', async () => {
+ endpoint = 'https://fcm.googleapis.com/fcm/send/abc'; windows = [];
+ respond = async () => true;
+ expect(await push(nwc)).toEqual([]);
+ windows = [hidden];
+ let asked = 0;
+ respond = async () => { asked++; return true; };
+ expect(await push(nwc)).toEqual([]);
+ expect(asked).toBe(0); // the open (if hidden) window answers on Android
+ windows = [];
+ respond = async () => false;
+ expect(await push(nwc)).toEqual(['Payment request']);
+});
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/thread-reply-test.js b/tools/thread-reply-test.js
index 738a31c..f3bda91 100644
--- a/tools/thread-reply-test.js
+++ b/tools/thread-reply-test.js
@@ -78,6 +78,27 @@ try {
await page.click('#app .note-act');
check('Reply immediately shows its composer beneath the tapped post', await inline());
check('the input receives focus before relay queries finish', await page.evaluate(() => document.activeElement.matches('.thread-reply-input')));
+ // Cancel with text typed: the box folds and the draft is gone (it used to
+ // stay open — the box shows while a draft has text, and Cancel only
+ // cleared the replying flag)
+ await page.type('.thread-reply-input', 'Half a thought');
+ await page.evaluate(() => document.querySelector('.thread-reply-cancel').click());
+ check('Cancel with text typed hides the box and discards the draft', await page.evaluate(() =>
+ !document.querySelector('.thread-reply-input') && !(test.ui.noteThread.draft || '').length));
+ await page.evaluate(() => document.querySelector('#app .note-act').click()); // one atomic tap, as below
+ await page.waitForSelector('.thread-reply-input');
+ check('Reply again opens an empty field', await page.evaluate(() => document.querySelector('.thread-reply-input')?.value === ''));
+ // a phone: Return is a new line, not a send
+ await page.evaluate(() => { const mm = window.matchMedia; window._mm = mm; window.matchMedia = (q) => q === '(pointer: coarse)' ? { matches: true } : mm(q); });
+ await page.type('.thread-reply-input', 'Line one');
+ await page.keyboard.press('Enter');
+ await page.type('.thread-reply-input', 'two');
+ check("on a phone, Return adds a line instead of sending", await page.evaluate(() =>
+ document.querySelector('.thread-reply-input')?.value === 'Line one\ntwo' && sent.length === 0), await page.evaluate(() => document.querySelector('.thread-reply-input')?.value));
+ await page.evaluate(() => { window.matchMedia = window._mm; });
+ await page.focus('.thread-reply-input');
+ await page.keyboard.down('Control'); await page.keyboard.press('a'); await page.keyboard.up('Control');
+ await page.keyboard.press('Backspace');
await page.type('.thread-reply-input', 'A reply before the root loads');
// (one atomic tap: a repaint landing between finding the button and clicking it replaces it)
await page.waitForFunction(() => !document.querySelector('.thread-reply-send')?.disabled);
diff --git a/tools/video-events-test.js b/tools/video-events-test.js
index bef0956..c024949 100644
--- a/tools/video-events-test.js
+++ b/tools/video-events-test.js
@@ -128,6 +128,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(500);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('a tap opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
const slides = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('|')[1].replace(location.origin, '')));
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
index 85250c0..93726e1 100644
--- a/tools/video-fallback-test.js
+++ b/tools/video-fallback-test.js
@@ -37,6 +37,12 @@ const server = Bun.serve({ port: 0, fetch: (req) => {
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
+// a browser without HLS of its own (Firefox): a clip too big to play as it
+// is waits for a tap here, rather than streaming (video-hls-play-test)
+await page.evaluateOnNewDocument(() => {
+ const can = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : can.call(this, t); };
+});
await page.setRequestInterception(true);
const asked = []; // [kind, clip url]
page.on('request', (r) => {
diff --git a/tools/video-gone-test.js b/tools/video-gone-test.js
new file mode 100644
index 0000000..95826b5
--- /dev/null
+++ b/tools/video-gone-test.js
@@ -0,0 +1,129 @@
+// A post whose clip its host no longer has (deleted from Primal's blossom:
+// a 404 with no CORS headers, which the probe can't read) sat in the feed
+// as a dead player under "Tap to play" that did nothing when tapped —
+// "Video in this post won't play" (Adam, 2026-11-01). The player is the
+// first to know the file is gone: it now says so where it stood, with the
+// file's link, its controls and cover gone; the next paint of the post says
+// it at once, with no request. A clip on a live host plays as before.
+// Run: bun tools/video-gone-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 GOOD = '/tmp/coinos-vgone-good.webm';
+if (!existsSync(GOOD)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '8', '-c:v', 'libvpx', '-b:v', '100k', GOOD]);
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const html = await buildHtml({ minify: !process.env.VGONE_LOG, pwa: false });
+const app = 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' } });
+} });
+// the clips' host: another origin, no CORS headers at all (Primal's blossom)
+let deadHits = 0;
+const host = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/dead.mp4') { deadHits++; if (process.env.VGONE_LOG) console.error('dead.mp4 asked:', req.method, req.headers.get('range') || '-', req.headers.get('sec-fetch-dest'), req.headers.get('sec-fetch-mode')); return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } }); }
+ if (path === '/good.webm') {
+ const f = Bun.file(GOOD), 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) } });
+ }
+ return new Response('no', { status: 404 });
+} });
+const DEAD = host.url.origin + '/dead.mp4', LIVE = host.url.origin + '/good.webm';
+const post = (content, url, ago, m) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'm ' + m]], content }, SK);
+const seeded = [post('lol beat it ' + DEAD, DEAD, 100, 'video/mp4'), post('a clip that is there ' + LIVE, LIVE, 200, 'video/webm'),
+ ...Array.from({ length: 4 }, (_, 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(() => {
+ 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)));
+if (process.env.VGONE_LOG) page.on('request', (r) => { if (r.url().endsWith('/dead.mp4')) console.error('REQ', r.resourceType(), JSON.stringify(r.initiator()).slice(0, 900)); });
+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 boxOf = (word) => page.evaluate((w) => {
+ const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w));
+ const b = r && r.querySelector('.note-video-box'); if (!b) return null;
+ const v = b.querySelector('video');
+ return { gone: b.classList.contains('gone'), line: (b.querySelector('.vid-nopic') || {}).textContent || '', link: (b.querySelector('.vid-nopic a') || {}).href || '', cover: !!b.querySelector('.vid-heavy'), pp: !!b.querySelector('.vid-pp'), controls: !!v.controls, height: Math.round(b.getBoundingClientRect().height), playing: !v.paused, src: v.getAttribute('src') };
+}, word);
+const scrollTo = async (word) => {
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); }, word);
+ await sleep(1500);
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(app.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 >= 2, null, 20000));
+ };
+
+ console.log('\n[the clip whose host answers 404, without CORS]');
+ await open();
+ await scrollTo('beat it');
+ let b = await boxOf('beat it');
+ check('the probe could not tell: the clip waits under a cover', !!b && b.cover && !b.gone, JSON.stringify(b));
+ // the tap that used to do nothing
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.vid-heavy').click(); }, 'beat it');
+ check('the player finds the file gone and says so', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return !!r.querySelector('.note-video-box.gone .vid-nopic'); }, 'beat it', 8000));
+ b = await boxOf('beat it');
+ check('…the line names the host, with the file\'s link', /couldn’t be loaded from its host/.test(b.line) && b.link === DEAD, JSON.stringify(b));
+ check('…no cover, no play button, no controls', !b.cover && !b.pp && !b.controls, JSON.stringify(b));
+ check('…a short box, not a screen of black', b.height > 40 && b.height <= 200, String(b.height));
+ const hitsThen = deadHits;
+ await sleep(2500); // repaints, the in-view hook: nothing asks again
+ b = await boxOf('beat it');
+ check('the line stays through repaints', b.gone && !!b.line && !b.cover, JSON.stringify(b));
+
+ console.log('\n[the clip on a live host]');
+ await scrollTo('that is there');
+ // the same quiet host: the cover, and the tap that plays it
+ await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const c = r.querySelector('.vid-heavy'); if (c) c.click(); }, 'that is there');
+ check('plays as before', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const v = r.querySelector('video'); return !!v && !v.paused && v.readyState >= 2; }, 'that is there', 10000), JSON.stringify(await boxOf('that is there')));
+ b = await boxOf('that is there');
+ check('…and is not marked gone', !b.gone && !b.line, JSON.stringify(b));
+
+ console.log('\n[the next time the post is painted]');
+ await open();
+ await scrollTo('beat it');
+ b = await boxOf('beat it');
+ check('the post says it at once', !!b && b.gone && /couldn’t be loaded/.test(b.line) && !b.cover, JSON.stringify(b));
+ await sleep(1500);
+ check('…without asking the host again', deadHits === hitsThen, deadHits + ' vs ' + hitsThen);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); app.stop(true); host.stop(true); }
+console.log(ok ? '\n✅ a clip its host no longer has says so, in place of a dead player' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
new file mode 100644
index 0000000..f1aea1c
--- /dev/null
+++ b/tools/video-hls-play-test.js
@@ -0,0 +1,133 @@
+// A long clip in the feed plays as HLS from vmedia (a rendition the
+// connection carries, in pieces made as watched), and so does one too big
+// to play as it is (past 1080p: it used to wait for a tap); a short one as it is,
+// and a post linking an .m3u8 stream plays that. vmedia runs here for real,
+// on a local copy of the clip.
+// Run: bun tools/video-hls-play-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { readFileSync, rmSync } from 'node:fs';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const DIR = process.env.CLIP_DIR || '/tmp';
+const ff = (out, codec) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3',
+ '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
+ return readFileSync(out);
+};
+const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']);
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']);
+const LONG = readFileSync(DIR + '/coinos-hls-long.mp4');
+// short but past 1080p: it used to wait for a tap with its cost on it
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=1440x1440:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-big.mp4']);
+const BIG = readFileSync(DIR + '/coinos-hls-big.mp4');
+// vmedia itself, reading the clip from here
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => new Response(new URL(req.url).pathname === '/big.mp4' ? BIG : LONG) });
+const local = (src) => src.replace('https://media.test/', `http://127.0.0.1:${files.port}/`);
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = DIR + '/coinos-hls-vmedia';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8', big: 'https://media.test/big.mp4' };
+const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300), post('square ' + CLIP.big, 400)];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = []; // [kind, clip url]
+// vmedia's answers, from the real thing; the clip's host serves the files
+const vmedia = async (u) => {
+ if (u.pathname === '/vmedia/hls.m3u8') u.searchParams.set('u', local(u.searchParams.get('u')));
+ const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search));
+ return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } };
+};
+page.on('request', async (r) => {
+ const u = new URL(r.url());
+ if (u.pathname.startsWith('/vmedia/hls')) {
+ asked.push(['hls', u.pathname]);
+ return r.respond(await vmedia(u));
+ }
+ if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
+ if (u.host === 'media.test') {
+ asked.push([r.resourceType(), r.url()]);
+ // the stream a post links: vmedia's of the long clip, served from its host
+ if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); }
+ return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : u.pathname === '/big.mp4' ? BIG : SHORT, headers: { 'access-control-allow-origin': '*' } });
+ }
+ r.continue();
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`;
+const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video');
+ return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`));
+const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the four clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 4, null, 20000),
+ String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length)));
+
+ console.log('\n[a long clip]');
+ await show('lecture');
+ check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('lecture')));
+ check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4)));
+
+ console.log('\n[a short clip past 1080p]');
+ await page.evaluate(new Function(`${box('square')}.scrollIntoView({ block: 'center' })`));
+ check('plays by itself as HLS, no tap-to-play cover', await waitFor(new Function(`const b = ${box('square')}, v = b?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.3 && !b.querySelector('.vid-heavy');`), null, 20000),
+ JSON.stringify(await player('square')));
+
+ console.log('\n[a short clip]');
+ await show('quick');
+ check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000),
+ JSON.stringify(await player('quick')));
+
+ console.log('\n[a post linking a stream]');
+ await show('episode');
+ check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('episode')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); }
+console.log(ok ? '\n✅ long clips and streams play as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-tap-test.js b/tools/video-tap-test.js
index f82238b..50e5a69 100644
--- a/tools/video-tap-test.js
+++ b/tools/video-tap-test.js
@@ -1,8 +1,7 @@
-// Tapping a video: in the feed it opens the media pager (feed-video-pager-
-// test.js has the rest). In a thread, anywhere on the picture (not the
-// player's own play button or control bar) goes full screen — the corner
-// button is a reach on a phone. Full screen, a tap pauses a playing clip; a
-// tap on a paused one comes back out. Taps on the controls stay the player's.
+// Tapping a video: inline, a playing clip pauses. A paused one, in the feed,
+// and in a thread opens the media pager (feed-video-pager-test.js has the
+// rest). With a mouse the middle of the clip is a big play/pause button that
+// never goes full screen.
// Run: bun tools/video-tap-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
@@ -69,8 +68,18 @@ try {
for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250);
check('the clip autoplays in the feed', !(await st()).paused);
+ // the hover button: pauses and plays in place
+ const pp = async () => { const b = await page.evaluate(() => { const r = document.querySelector('.notes-feed .note-video-box').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.move(b.x - 40, b.y); await page.mouse.move(b.x, b.y, { steps: 3 }); await sleep(250); if (!(await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'flex'))) check('a mouse over the clip brings up the middle button', false); await page.mouse.click(b.x, b.y); await sleep(700); };
+ check('no middle button until a mouse comes over the clip', await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'none'));
+ await pp();
+ check('the middle button pauses the clip in place', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement));
+ check('...and shows play while paused', await page.evaluate(() => !document.querySelector('.vid-pp').classList.contains('playing')));
+ await pp();
+ check('the middle button plays it again, still inline', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement));
await tap(0.2, 0.3);
- check('in the feed, a tap opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ check('in the feed, a tap on a playing clip pauses it, no pager', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager')));
+ await tap(0.2, 0.3);
+ check('a tap on the paused clip opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
await page.keyboard.press('Escape'); await sleep(700);
check('...which Escape closes', await page.evaluate(() => !document.querySelector('.media-pager') && !document.fullscreenElement));
// the thread: the post's words open it
@@ -87,22 +96,11 @@ try {
for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250);
await tap(0.2, 0.3);
let s = await st();
- check('in a thread, a tap on the picture goes full screen', s.fs, JSON.stringify(s));
- check('...and not the pager', await page.evaluate(() => !document.querySelector('.media-pager')));
- await tap(0.25, 0.3);
- s = await st();
- check('full screen, a tap pauses the playing clip', s.fs && s.paused, JSON.stringify(s));
- await tap(0.5, 0.5);
- s = await st();
- check('a tap on the paused clip\'s play button is left to the player', s.fs, JSON.stringify(s));
- await page.evaluate(() => document.querySelector('.thread-card video.note-video').pause()); await sleep(300);
+ check('in a thread, a tap on the playing clip pauses it in place', !s.fs && s.paused, JSON.stringify(s));
await tap(0.2, 0.3);
- s = await st();
- check('a tap elsewhere on the paused clip comes out of full screen', !s.fs, JSON.stringify(s));
- await tap(0.3, 0.97);
- s = await st();
- check('a tap on the control bar stays the player\'s', !s.fs, JSON.stringify(s));
+ check('a tap on the paused clip opens the media pager there too', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await page.keyboard.press('Escape'); await sleep(700);
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
-console.log(ok ? '\n✅ video taps: full screen, pause, back out' : '\n❌ failed');
+console.log(ok ? '\n✅ video taps: pause in place, pager when paused, middle button' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/video-tile-poster-test.js b/tools/video-tile-poster-test.js
index 3b5fe93..48385fe 100644
--- a/tools/video-tile-poster-test.js
+++ b/tools/video-tile-poster-test.js
@@ -1,7 +1,8 @@
// Videos grid: a tile shows the server's still (vmedia thumb.jpg, cached at
// the CDN) at once, instead of waiting on the browser's own probe and frame
// grab — and never touches the clip itself for it. A clip the server has no
-// picture for falls back to the frame grab.
+// picture for falls back to the frame grab, and one the browser can't load
+// either (deleted, its 404 without CORS headers) leaves the grid.
// Run: bun tools/video-tile-poster-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
@@ -13,12 +14,17 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? '
const JPG = '/tmp/coinos-video-tile-poster.jpg';
Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=orange:s=270x480', '-frames:v', '1', JPG]);
const JPEG = Buffer.from(await Bun.file(JPG).arrayBuffer());
+const MP4 = '/tmp/coinos-video-tile-poster.mp4';
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=blue:s=360x640:d=1', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', MP4]);
+const CLIPBYTES = Buffer.from(await Bun.file(MP4).arrayBuffer());
const AUTHOR = (() => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; })();
const now = Math.floor(Date.now() / 1000);
const CLIP = (n) => 'https://media.test/' + n + '.mp4';
+// (each its own size: one person's clips of one size to the byte are taken
+// for one clip uploaded twice)
const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, content: 'clip ' + n + ' ' + CLIP(n),
- tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size 900000', 'dim 360x640']] }, AUTHOR.sk);
-const POSTS = [post('a', 100), post('b', 200), post('nopic', 300)];
+ tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size ' + (900000 + ago), 'dim 360x640']] }, AUTHOR.sk);
+const POSTS = [post('a', 100), post('b', 200), post('nopic', 300), post('dead', 400)];
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -31,10 +37,16 @@ page.on('request', (r) => {
if (u.pathname.startsWith('/vmedia/thumb.jpg')) {
const src = u.searchParams.get('u');
asked.push(['thumb', src]);
- if (src === CLIP('nopic')) return r.respond({ status: 404, body: 'No picture' });
+ if (src === CLIP('nopic') || src === CLIP('dead')) return r.respond({ status: 404, body: 'No picture' });
return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
}
- if (u.host === 'media.test') { asked.push(['clip', r.url()]); return r.abort(); }
+ if (u.host === 'media.test') {
+ asked.push(['clip', r.url()]);
+ // deleted, and the host's 404 says nothing to scripts (Primal's blossom)
+ if (u.pathname === '/dead.mp4') return r.respond({ status: 404, body: 'not found' });
+ if (u.pathname === '/nopic.mp4') return r.respond({ status: 200, contentType: 'video/mp4', body: CLIPBYTES, headers: { 'access-control-allow-origin': '*' } });
+ return r.abort();
+ }
r.continue();
});
await page.evaluateOnNewDocument(() => {
@@ -79,17 +91,31 @@ try {
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
- check('the Videos grid has the three clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 15000),
+ check('the Videos grid has the clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
check('two tiles show the server\'s still, loaded', await waitFor(() => [...document.querySelectorAll('.media-tile.video img')]
.filter((i) => i.src.includes('/vmedia/thumb.jpg') && i.naturalWidth > 0).length === 2, 8000),
JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].map((t) => (t.querySelector('img')?.src || t.querySelector('video') ? 'video' : t.innerHTML).slice(-40)))));
check('the clip without one falls back to the frame grab', await waitFor(() => [...document.querySelectorAll('.media-tile.video')]
.some((t) => !t.querySelector('img') && (t.querySelector('video') || t.querySelector('.media-tile-cost'))), 8000));
+ check('the deleted clip leaves the grid instead of sitting there blank', await waitFor(() => {
+ const tiles = [...document.querySelectorAll('.media-grid .media-tile.video')].filter((t) => getComputedStyle(t).display !== 'none');
+ return tiles.length === 3 && !document.querySelector('.media-grid video[src*="dead"]');
+ }, 10000), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
await sleep(1500);
- const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic'));
+ const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic') && !u.includes('dead'));
check('the pictured clips themselves were never fetched for a still', clipHits.length === 0, clipHits.map((x) => x[1]).join(', '));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ // remembered: the next visit leaves it out from the start
+ await sleep(2000);
+ asked.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000);
+ await sleep(3000);
+ check('...and stays out after a reload', await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length) === 3
+ && !asked.some(([k, u]) => k === 'clip' && u.includes('dead')), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ video tiles show the server\'s still at once' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js
new file mode 100644
index 0000000..05961a8
--- /dev/null
+++ b/tools/vmedia-hls-test.js
@@ -0,0 +1,84 @@
+// vmedia's HLS: a clip's playlists come from its length before anything is
+// made, segments are made from wherever the player asks (a seek starts the
+// encoder there), the encoder waits once well ahead of the player, and
+// Chrome plays the stream and seeks in it.
+// Run: bun tools/vmedia-hls-test.js
+import puppeteer from './puppeteer.js';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/vmedia-hls-');
+const clip = dir + '/clip.mp4';
+// a minute of 1080p with sound
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]);
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) });
+const SRC = `http://127.0.0.1:${files.port}/clip.mp4`;
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = dir + '/data';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle });
+const V = `http://127.0.0.1:${api.port}/vmedia`;
+const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers });
+ return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } });
+} });
+let browser;
+try {
+ console.log('[the playlists]');
+ const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text();
+ check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300));
+ const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1];
+ const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text();
+ const segs = media.split('\n').filter((l) => /\.ts$/.test(l));
+ check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments');
+ check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720'));
+ check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400);
+
+ console.log('\n[segments, made as asked]');
+ let t0 = Date.now();
+ const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`);
+ const b0 = await s0.arrayBuffer();
+ check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms');
+ t0 = Date.now();
+ const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`);
+ await s10.arrayBuffer();
+ check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms');
+ const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format;
+ const p0 = probe(0), p12 = probe(12);
+ check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' → ' + p12.start_time);
+ await sleep(4000);
+ const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f));
+ check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' '));
+
+ console.log('\n[Chrome plays it]');
+ browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+ const page = await browser.newPage();
+ await page.goto(page0.url.href);
+ const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`;
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration };
+ v.currentTime = 30;
+ const t1 = performance.now();
+ while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, hlsSrc);
+ check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first));
+ check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur));
+ check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ if (browser) await browser.close();
+ files.stop(true); api.stop(true); page0.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\n✅ vmedia serves long clips as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/zap-feedback-test.js b/tools/zap-feedback-test.js
index adc7663..80ec434 100644
--- a/tools/zap-feedback-test.js
+++ b/tools/zap-feedback-test.js
@@ -124,7 +124,7 @@ try {
check('the same tap plays the supplied MP3 clip', await page.evaluate(() => {
const samples = zapAudio.buffer?.getChannelData(0);
return zapAudio.starts === 1 && zapAudio.context.state === 'running'
- && zapAudio.buffer.duration > 1.2 && zapAudio.buffer.duration < 1.6
+ && zapAudio.buffer.duration > 0.3 && zapAudio.buffer.duration < 0.5
&& samples.some(v => Math.abs(v) > .05)
&& samples.every(v => Number.isFinite(v));
}));
@@ -202,8 +202,9 @@ try {
// The same screen switches the sound off; the strike and tally stay.
await page.mouse.down(); await pause(650); await page.mouse.up(); await pause(100);
check('the held screen offers the sound switch, on by default', await page.$eval('#setup input[type=checkbox]', (e) => e.checked));
- await page.click('#setup input[type=checkbox]');
- await page.click('#setup .btn-primary'); await pause(100);
+ // the toggle re-renders the screen mid-click: dispatch the clicks in the page, where a morph can't detach the node under the pointer
+ await page.$eval('#setup input[type=checkbox]', (e) => e.click()); await pause(150);
+ await page.$eval('#setup .btn-primary', (e) => e.click()); await pause(100);
const starts = await page.evaluate(() => zapAudio.starts);
await page.click('.note-zap'); await pause(100);
check('with the sound off a tap still strikes and counts, silently', await page.evaluate((n, c) => zapAudio.starts === n && test.calls === c + 2 && !!document.querySelector('.zap-fx'), starts, calls));
diff --git a/tools/zap-signer-test.js b/tools/zap-signer-test.js
new file mode 100644
index 0000000..13f6008
--- /dev/null
+++ b/tools/zap-signer-test.js
@@ -0,0 +1,43 @@
+// A zap is signed as the person zapping: with a login identity (extension,
+// bunker, passkey…) the zap request's pubkey is that identity, not the
+// wallet's own key — a signed-in person's zaps showed in stream chats (and
+// streamers' overlays) under the wallet key's stranger npub. Without a
+// login, the wallet key signs as before.
+// Run: bun tools/zap-signer-test.js
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { zapsFeature } from '../src/features/zaps.js';
+
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const walletSk = generateSecretKey(), loginSk = generateSecretKey(), host = getPublicKey(generateSecretKey());
+let asked = null;
+globalThis.fetch = async (url) => {
+ const u = new URL(url);
+ if (u.pathname.startsWith('/.well-known/lnurlp/')) return Response.json({ tag: 'payRequest', callback: 'https://ln.example/cb', minSendable: 1000, maxSendable: 1e9, allowsNostr: true, nostrPubkey: host });
+ if (u.pathname === '/cb') { asked = JSON.parse(u.searchParams.get('nostr')); return Response.json({ pr: 'lnbc210n1fake' }); }
+ return new Response('no', { status: 404 });
+};
+async function zapWith(login) {
+ asked = null;
+ let settled = null;
+ const ctx = {
+ h: () => null, ui: {}, render: () => {}, toast: (m) => console.log('toast:', m), fmtAmount: String, unitLabel: () => 'sats', parseAmount: Number, getUnit: () => 'sat', unitTag: () => '', blankSend: () => {},
+ wallet: { watchOnly: false, nostrRelays: () => ['wss://relay.example'], nostrProfile: async () => ({ lud16: '[email protected]' }), nostrSign: (e) => finalizeEvent(e, walletSk) },
+ hook(name, ...a) {
+ if (name === 'canLnPay' || name === 'arkReady') return true;
+ if (name === 'nostrLoginIdentity') return login;
+ if (name === 'arkPayInvoice') return Promise.resolve({});
+ if (name === 'zapSettled') { settled = a; return null; }
+ return null;
+ },
+ };
+ zapsFeature(ctx).lnZapNpub(host, null, 'e'.repeat(64), 21);
+ for (let i = 0; i < 50 && !settled; i++) await new Promise((r) => setTimeout(r, 20));
+ return { req: asked, settled };
+}
+const signedIn = await zapWith({ pubkey: getPublicKey(loginSk), signer: { signEvent: async (e) => finalizeEvent(e, loginSk) } });
+check('signed in: the zap goes through', signedIn.settled && signedIn.settled[1] === true, JSON.stringify(signedIn.settled));
+check('...and its request is signed by the login identity', signedIn.req && signedIn.req.kind === 9734 && signedIn.req.pubkey === getPublicKey(loginSk), signedIn.req && signedIn.req.pubkey);
+const plain = await zapWith(null);
+check('no login: the wallet\'s own key signs it', plain.req && plain.req.pubkey === getPublicKey(walletSk));
+console.log('\n✅ zaps are signed as the person zapping');
diff --git a/vmedia/server.js b/vmedia/server.js
index b2f5bae..6b6b69a 100644
--- a/vmedia/server.js
+++ b/vmedia/server.js
@@ -4,6 +4,9 @@
// /vmedia/thumb.jpg?u=<clip> a still from the clip's first half second,
// 480px wide — the poster a post's imeta didn't give. A 4K or 134 MB
// clip waits for a tap, so without one the feed showed a black box.
+// /vmedia/first.jpg?u=<clip> the clip's very first frame, at its size
+// (longest side 1280): the very picture a player shows first, so the
+// video pager's slide ahead holds it before the player has it.
// /vmedia/h264.mp4?u=<clip> the clip as H.264/AAC at 720p, for a browser
// that can't decode the original's picture (an iPhone's HEVC on Chrome
// for Linux plays its sound over black). The first ask streams the
@@ -17,18 +20,26 @@
// camera file froze a wheel scroll for 400 ms on its texture upload.
// libvips makes the copy (shrink-on-load: the original is never decoded
// whole), turned the way its EXIF says, stripped, as WebP.
+// /vmedia/hls.m3u8?u=<clip> the clip as HLS (Adam, 2026-10-18; jb55: "you
+// should use hls (m3u8 ext) for long videos"): a 360p and a 720p H.264
+// rendition in 4-second segments, the player choosing between them as
+// the connection allows. Nothing is made ahead of time: the playlists
+// are written from the clip's length, and each rendition's encoder runs
+// from the segment asked for, a minute ahead of the player at most (then
+// paused), restarted wherever a seek lands, and stopped once nobody is
+// asking. Segments made are kept, like the copies.
//
// Only https sources on public addresses; ffmpeg may speak nothing else. A
// few jobs at a time, a per-address hourly allowance for new transcodes,
// and both caches trimmed to a size.
-import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync } from 'node:fs';
+import { mkdirSync, existsSync, statSync, readdirSync, unlinkSync, renameSync, utimesSync, openSync, readSync, closeSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { createHash } from 'node:crypto';
const DATA = process.env.DATA || '/data';
-const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work';
-for (const d of [THUMBS, CLIPS, PICS, WORK]) mkdirSync(d, { recursive: true });
+const THUMBS = DATA + '/thumbs', CLIPS = DATA + '/h264', PICS = DATA + '/pics', WORK = DATA + '/work', HLS = DATA + '/hls';
+for (const d of [THUMBS, CLIPS, PICS, WORK, HLS]) mkdirSync(d, { recursive: true });
const THUMB_CAP = 2e9, CLIP_CAP = 25e9, PIC_CAP = 8e9;
const THUMB_JOBS = 8, CLIP_JOBS = 2, CLIP_QUEUE = 6, PIC_JOBS = 4, PIC_QUEUE = 60;
const CLIP_PER_HOUR = 12; // new transcodes per address
@@ -52,9 +63,11 @@ const privateV6 = (ip) => {
};
export const privateIp = (ip) => (isIP(ip) === 6 ? privateV6(ip) : privateV4(ip));
// a clip worth fetching: https, an ordinary port, a public name
+const LOCAL = process.env.VMEDIA_LOCAL === '1'; // tests: clips served over http from this machine
export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
let u;
try { u = new URL(raw); } catch { return null; }
+ if (LOCAL && u.protocol === 'http:' && u.hostname === '127.0.0.1') return u.href;
if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443') || raw.length > 2000) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) return null;
@@ -66,7 +79,7 @@ export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) {
}
// ---- ffmpeg ---------------------------------------------------------------
-const NET = ['-protocol_whitelist', 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia'];
+const NET = ['-protocol_whitelist', LOCAL ? 'http,https,tls,tcp,crypto' : 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia'];
// longest side at most 1280, even numbers for the encoder
export const SCALE_720 = "scale='trunc(min(1,1280/max(iw,ih))*iw/2)*2':'trunc(min(1,1280/max(iw,ih))*ih/2)*2'";
export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src,
@@ -74,6 +87,9 @@ export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel'
// that fades in from black has a black frame at half a second
'-frames:v', '1', '-vf', "scale='min(480,iw)':-2," + (lift ? 'normalize=strength=0.7:smoothing=0,' : '') + "signalstats,metadata=mode=print:key=lavfi.signalstats.YAVG:file='pipe\\:2'",
'-q:v', '5', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1'];
+// frame 0 itself, as big as the player shows it (the pager's slide ahead)
+export const firstArgs = (src) => ['-hide_banner', '-loglevel', 'error', ...NET, '-i', src,
+ '-frames:v', '1', '-vf', SCALE_720, '-q:v', '3', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1'];
export const clipArgs = (src, out) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET, '-i', src,
'-map', '0:v:0', '-map', '0:a:0?', '-t', String(MAX_SECONDS), '-vf', SCALE_720,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-threads', '4',
@@ -131,6 +147,26 @@ async function makeThumb(src, key) {
failed.set(key, Date.now());
return null;
}
+// The very first frame, full size, for the video pager: the slide ahead
+// holds the picture its player will show first, so a flick glides onto it
+// and the player takes over with no visible change. Dark is dark: this one
+// is meant to match the player, not to show what the clip is.
+async function makeFirst(src, key) {
+ const out = `${THUMBS}/${key}-f.jpg`;
+ const { p, done } = run(firstArgs(src), 25_000);
+ const [buf] = await Promise.all([new Response(p.stdout).arrayBuffer(), done]);
+ if (p.exitCode === 0 && buf.byteLength > 500) { await Bun.write(out, buf); return out; }
+ failed.set(key, Date.now());
+ return null;
+}
+const FIRST_V = 1;
+async function first(src) {
+ const key = keyOf('f' + FIRST_V + '|' + src), out = `${THUMBS}/${key}-f.jpg`;
+ if (existsSync(out)) { touch(out); return out; }
+ if (failedLately(key)) return null;
+ if (!thumbJobs.has(key)) thumbJobs.set(key, thumbSlots(() => makeFirst(src, key)).finally(() => thumbJobs.delete(key)));
+ return thumbJobs.get(key);
+}
const THUMB_V = 4; // v4: past a dark opening frame (stills made before are made again)
async function thumb(src) {
const key = keyOf('v' + THUMB_V + '|' + src), out = `${THUMBS}/${key}.jpg`;
@@ -202,6 +238,171 @@ function tail(job) {
});
}
+// ---- HLS --------------------------------------------------------------------
+const SEG = 4; // seconds a segment
+const HLS_CAP = 30e9, HLS_ENCODERS = 6, HLS_PER_HOUR = 30, HLS_MAX_SECONDS = 4 * 3600;
+const AHEAD = 15; // segments an encoder runs ahead of the player before it waits
+const IDLE_MS = 90_000; // an encoder nobody has asked anything of for this long stops
+const RUNGS = [{ name: '360', long: 640, vb: 800, ab: 96 }, { name: '720', long: 1280, vb: 2800, ab: 128 }];
+const HLS_V = 1;
+const hlsKey = (src) => keyOf('h' + HLS_V + '|' + src);
+const hlsAllowance = new Map();
+const even = (x) => Math.max(2, Math.round(x / 2) * 2);
+// the rendition sizes a clip gets: never larger than it is; one at least
+export function rungsFor(w, h) {
+ const long = Math.max(w, h);
+ const out = RUNGS.filter((r, i) => !i || long >= r.long * 0.9).map((r) => {
+ const k = Math.min(1, r.long / long);
+ return { ...r, w: even(w * k), h: even(h * k) };
+ });
+ return out;
+}
+// what ffprobe says: its length, its picture's size the way it's shown, any sound
+export function readProbe(j) {
+ const v = (j.streams || []).find((s) => s.codec_type === 'video');
+ const dur = Number(j.format?.duration);
+ if (!v || !v.width || !v.height || !(dur > 0)) return null;
+ const rot = Number((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0);
+ const turned = Math.abs(rot) % 180 === 90;
+ return { dur, w: turned ? v.height : v.width, h: turned ? v.width : v.height, audio: (j.streams || []).some((s) => s.codec_type === 'audio') };
+}
+const probing = new Map();
+async function hlsInfo(src, key) {
+ const dir = `${HLS}/${key}`, file = `${dir}/info.json`;
+ if (existsSync(file)) { touch(dir); try { return JSON.parse(readFileSync(file, 'utf8')); } catch {} }
+ if (failedLately(key)) return null;
+ if (!probing.has(key)) probing.set(key, (async () => {
+ const p = Bun.spawn(['ffprobe', '-v', 'error', ...NET, '-show_format', '-show_streams', '-of', 'json', src], { stdout: 'pipe', stderr: 'ignore' });
+ const timer = setTimeout(() => p.kill(9), 25_000);
+ const out = await new Response(p.stdout).text();
+ await p.exited.finally(() => clearTimeout(timer));
+ let info = null;
+ try { info = readProbe(JSON.parse(out)); } catch {}
+ if (!info || info.dur > HLS_MAX_SECONDS) { failed.set(key, Date.now()); return null; }
+ info = { src, ...info, rungs: rungsFor(info.w, info.h) };
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(file, JSON.stringify(info));
+ return info;
+ })().finally(() => probing.delete(key)));
+ return probing.get(key);
+}
+const segCount = (info) => Math.max(1, Math.ceil(info.dur / SEG - 0.01));
+export function masterPlaylist(info, key) {
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-INDEPENDENT-SEGMENTS'];
+ for (const r of info.rungs) {
+ const bw = Math.round((r.vb * 1.1 + (info.audio ? r.ab : 0)) * 1000);
+ lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bw},RESOLUTION=${r.w}x${r.h},CODECS="avc1.640028${info.audio ? ',mp4a.40.2' : ''}"`, `/vmedia/hls/${key}/${r.name}.m3u8`);
+ }
+ return lines.join('\n') + '\n';
+}
+// the whole clip's segments, known from its length before any is made
+export function mediaPlaylist(info, rung) {
+ const n = segCount(info);
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:3', `#EXT-X-TARGETDURATION:${SEG}`, '#EXT-X-PLAYLIST-TYPE:VOD', '#EXT-X-MEDIA-SEQUENCE:0'];
+ for (let i = 0; i < n; i++) {
+ const d = i < n - 1 ? SEG : Math.max(0.1, info.dur - SEG * (n - 1));
+ lines.push(`#EXTINF:${d.toFixed(3)},`, `${rung}/${String(i).padStart(5, '0')}.ts`);
+ }
+ lines.push('#EXT-X-ENDLIST');
+ return lines.join('\n') + '\n';
+}
+// One rendition from segment `from` on. The keyframes fall every SEG
+// seconds of the clip, so a segment made by a restarted encoder is the one
+// the first would have made (its times shifted by where it started).
+export const hlsArgs = (src, info, r, from, dir) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET,
+ '-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_delay_max', '5',
+ ...(from ? ['-ss', String(from * SEG)] : []), '-i', src,
+ '-map', '0:v:0', ...(info.audio ? ['-map', '0:a:0'] : []),
+ '-vf', `scale=${r.w}:${r.h}`, '-c:v', 'libx264', '-preset', 'veryfast', '-profile:v', 'high', '-pix_fmt', 'yuv420p',
+ '-b:v', r.vb + 'k', '-maxrate', Math.round(r.vb * 1.2) + 'k', '-bufsize', r.vb * 2 + 'k', '-threads', '2',
+ '-force_key_frames', `expr:gte(t,n_forced*${SEG})`,
+ ...(info.audio ? ['-c:a', 'aac', '-b:a', r.ab + 'k', '-ac', '2'] : []),
+ ...(from ? ['-output_ts_offset', String(from * SEG)] : []),
+ '-f', 'hls', '-hls_time', String(SEG), '-hls_list_size', '0', '-hls_segment_type', 'mpegts', '-hls_flags', 'temp_file',
+ '-start_number', String(from), '-hls_segment_filename', `${dir}/%05d.ts`, `${dir}/enc.m3u8`];
+const encoders = new Map(); // key/rung -> { p, from, next, last, at, paused, failed }
+const segPath = (dir, n) => `${dir}/${String(n).padStart(5, '0')}.ts`;
+function stopEncoder(id) {
+ const e = encoders.get(id);
+ if (!e) return;
+ encoders.delete(id);
+ try { if (e.paused) e.p.kill('SIGCONT'); e.p.kill(9); } catch {}
+}
+function startEncoder(id, src, info, r, from, dir) {
+ stopEncoder(id);
+ if (encoders.size >= HLS_ENCODERS) {
+ // the one asked of longest ago makes way, if it isn't in use
+ const [oldId, old] = [...encoders].sort((a, b) => a[1].at - b[1].at)[0];
+ if (Date.now() - old.at < 10_000) return null;
+ stopEncoder(oldId);
+ }
+ mkdirSync(dir, { recursive: true });
+ const p = Bun.spawn(['ffmpeg', ...hlsArgs(src, info, r, from, dir)], { stdout: 'ignore', stderr: 'pipe' });
+ const e = { p, from, next: from, last: from, at: Date.now(), paused: false, done: false, ok: false };
+ encoders.set(id, e);
+ p.exited.then(async (code) => {
+ e.done = true; e.ok = code === 0;
+ if (code !== 0 && code !== null && encoders.get(id) === e) console.log('hls encode failed', src, r.name, from, (await new Response(p.stderr).text()).slice(-300));
+ if (encoders.get(id) === e) encoders.delete(id);
+ });
+ return e;
+}
+// how far each encoder has got; ahead of its player it waits, idle it stops
+function tendEncoders() {
+ for (const [id, e] of encoders) {
+ const dir = `${HLS}/${id}`;
+ while (existsSync(segPath(dir, e.next))) e.next++;
+ if (Date.now() - e.at > IDLE_MS) { stopEncoder(id); continue; }
+ try {
+ if (!e.paused && e.next - e.last > AHEAD) { e.p.kill('SIGSTOP'); e.paused = true; }
+ else if (e.paused && e.next - e.last <= AHEAD - 3) { e.p.kill('SIGCONT'); e.paused = false; }
+ } catch {}
+ }
+}
+async function segResponse(req, key, rungName, n) {
+ const dir = `${HLS}/${key}`;
+ let info = null;
+ try { info = JSON.parse(readFileSync(`${dir}/info.json`, 'utf8')); } catch {}
+ const r = info && info.rungs.find((x) => x.name === rungName);
+ if (!r || n >= segCount(info)) return nope(404, 'No segment');
+ touch(dir);
+ const id = `${key}/${rungName}`, rdir = `${dir}/${rungName}`, file = segPath(rdir, n);
+ let e = encoders.get(id);
+ if (e) { e.at = Date.now(); e.last = Math.max(e.from, n); }
+ if (existsSync(file)) return fileResponse(req, file, 'video/mp2t');
+ // an encoder that will reach it soon, or one started there
+ if (e) while (existsSync(segPath(rdir, e.next))) e.next++;
+ if (!e || e.done || n < e.from || n > e.next + 2) e = startEncoder(id, info.src, info, r, n, rdir);
+ if (!e) return nope(503, 'Busy');
+ e.last = n;
+ tendEncoders();
+ const t0 = Date.now();
+ while (!existsSync(file) && Date.now() - t0 < 45_000) {
+ if (e.done) break;
+ await Bun.sleep(100);
+ }
+ return existsSync(file) ? fileResponse(req, file, 'video/mp2t') : nope(e.done ? 404 : 503, 'Not made');
+}
+const M3U8 = 'application/vnd.apple.mpegurl';
+const playlist = (text) => new Response(text, { headers: { 'Content-Type': M3U8, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' } });
+async function hlsResponse(req, route, u, ip) {
+ if (route === '/hls.m3u8') {
+ const src = await sourceOk(u.searchParams.get('u') || '');
+ if (!src) return nope(400, 'Not a public https URL');
+ const key = hlsKey(src);
+ if (!existsSync(`${HLS}/${key}/info.json`) && !failedLately(key) && !probing.has(key) && !allowed(ip, hlsAllowance, HLS_PER_HOUR)) return nope(429, 'Too many');
+ const info = await hlsInfo(src, key);
+ return info ? playlist(masterPlaylist(info, key)) : nope(404, 'Not playable');
+ }
+ const m = /^\/hls\/([0-9a-f]{64})\/(\w+)(?:\.m3u8|\/(\d{5})\.ts)$/.exec(route);
+ if (!m) return nope(404, 'Not found');
+ if (m[3] != null) return segResponse(req, m[1], m[2], Number(m[3]));
+ let info = null;
+ try { info = JSON.parse(readFileSync(`${HLS}/${m[1]}/info.json`, 'utf8')); } catch {}
+ if (!info || !info.rungs.some((r) => r.name === m[2])) return nope(404, 'Not found');
+ return playlist(mediaPlaylist(info, m[2]));
+}
+
// ---- responses --------------------------------------------------------------
const WEEK = 'public, max-age=604800, immutable';
function fileResponse(req, path, type) {
@@ -337,10 +538,21 @@ function trim(dir, cap) {
for (const f of files) { if (total <= cap) break; try { unlinkSync(f.p); total -= f.size; } catch {} }
} catch {}
}
+// the HLS clips go whole, the one asked of longest ago first (not one being made)
+function trimHls(cap) {
+ try {
+ const size = (p) => { let n = 0; for (const f of readdirSync(p, { withFileTypes: true })) n += f.isDirectory() ? size(p + '/' + f.name) : sizeOf(p + '/' + f.name); return n; };
+ const busy = new Set([...encoders.keys()].map((id) => id.split('/')[0]));
+ const dirs = readdirSync(HLS).filter((k) => !busy.has(k)).map((k) => ({ k, at: statSync(HLS + '/' + k).mtimeMs, size: size(HLS + '/' + k) })).sort((a, b) => a.at - b.at);
+ let total = dirs.reduce((n, d) => n + d.size, 0);
+ for (const d of dirs) { if (total <= cap) break; try { rmSync(HLS + '/' + d.k, { recursive: true, force: true }); total -= d.size; } catch {} }
+ } catch {}
+}
export function startTrim() {
- const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); };
+ const go = () => { trim(THUMBS, THUMB_CAP); trim(CLIPS, CLIP_CAP); trim(PICS, PIC_CAP); trimHls(HLS_CAP); };
go();
setInterval(go, 10 * 60_000);
+ setInterval(tendEncoders, 1000);
// work left over from a restart is no good to anyone
for (const f of readdirSync(WORK)) try { unlinkSync(WORK + '/' + f); } catch {}
}
@@ -349,16 +561,17 @@ export async function handle(req) {
const u = new URL(req.url);
if (!['GET', 'HEAD'].includes(req.method)) return nope(405, 'GET only');
const route = u.pathname.replace(/^\/vmedia/, '');
- if (route !== '/thumb.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found');
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown';
+ if (route.startsWith('/hls')) return hlsResponse(req, route, u, ip);
+ if (route !== '/thumb.jpg' && route !== '/first.jpg' && route !== '/h264.mp4' && route !== '/pic.webp') return nope(404, 'Not found');
const src = await sourceOk(u.searchParams.get('u') || '');
if (!src) return nope(400, 'Not a public https URL');
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || 'unknown';
if (route === '/pic.webp') {
const shape = u.searchParams.get('s') || 'col';
return SHAPES[shape] ? picResponse(req, src, shape, ip) : nope(400, 'Unknown shape');
}
- if (route === '/thumb.jpg') {
- const p = await thumb(src);
+ if (route === '/thumb.jpg' || route === '/first.jpg') {
+ const p = await (route === '/first.jpg' ? first(src) : thumb(src));
return p ? fileResponse(req, p, 'image/jpeg') : nope(404, 'No picture');
}
return clipResponse(req, src, ip);