aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 06:49:19 -0700
committerAdam Soltys <[email protected]>2026-10-01 06:49:19 -0700
commit68ff290e07c0e1786cf8b8f3a87e38872ad7f8ba (patch)
treec85847c8f4a06b0a6b7dab8abb2d2eb54fefa226
parent4b3291fdbd0b34b4d2df91a59bea93885de29508 (diff)
parent5ff909d37fe32ba04a841941593d95c7b3498ec3 (diff)
downloadcoinosv3-68ff290e07c0e1786cf8b8f3a87e38872ad7f8ba.tar.gz
coinosv3-68ff290e07c0e1786cf8b8f3a87e38872ad7f8ba.zip
Merge prod into master
-rw-r--r--AGENTS.md5
-rw-r--r--build.js13
-rw-r--r--bun.lock7
-rw-r--r--package.json1
-rw-r--r--src/app.js227
-rw-r--r--src/features/ark.js128
-rw-r--r--src/features/messages.js1116
-rw-r--r--src/feed-cache.js35
-rw-r--r--src/i18n.js22
-rw-r--r--src/lnurl.js15
-rw-r--r--src/locales/ar.json4
-rw-r--r--src/locales/bn.json4
-rw-r--r--src/locales/de.json4
-rw-r--r--src/locales/es.json4
-rw-r--r--src/locales/fr.json4
-rw-r--r--src/locales/hi.json4
-rw-r--r--src/locales/ja.json4
-rw-r--r--src/locales/ko.json4
-rw-r--r--src/locales/pt.json4
-rw-r--r--src/locales/ru.json4
-rw-r--r--src/locales/tr.json4
-rw-r--r--src/locales/zh.json4
-rw-r--r--src/media-upload.js117
-rw-r--r--src/nostr.js27
-rw-r--r--src/rich-text.js181
-rw-r--r--src/style.css88
-rw-r--r--src/thread-cache.js17
-rw-r--r--src/video-compress.js54
-rw-r--r--tools/account-access-test.js2
-rw-r--r--tools/avatar-profile-test.js124
-rw-r--r--tools/comments-test.js99
-rw-r--r--tools/compose-preview-test.js6
-rw-r--r--tools/composer-media-test.js103
-rw-r--r--tools/feed-bottom-nav-test.js99
-rw-r--r--tools/feed-cache-test.js26
-rw-r--r--tools/feed-nav-destination-test.js18
-rw-r--r--tools/feed-new-posts-test.js74
-rw-r--r--tools/feed-switch-seed-test.js125
-rw-r--r--tools/feed-video-aspect-test.js117
-rw-r--r--tools/feed-wallet-back-test.js25
-rw-r--r--tools/image-tap-test.js70
-rw-r--r--tools/image-zoom-test.js84
-rw-r--r--tools/media-upload-test.js61
-rw-r--r--tools/notif-note-cache-test.js128
-rw-r--r--tools/poll-test.js93
-rw-r--r--tools/post-actions-test.js2
-rw-r--r--tools/post-cancel-test.js20
-rw-r--r--tools/quote-card-test.js38
-rw-r--r--tools/room-faces-test.js88
-rw-r--r--tools/thread-cache-test.js19
-rw-r--r--tools/topic-depth-test.js36
-rw-r--r--tools/upload-progress-test.js37
-rw-r--r--tools/video-compress-test.js126
53 files changed, 3388 insertions, 333 deletions
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..7d500e6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,5 @@
+# User preferences
+
+- After completing and validating a requested change in this project, deploy it to production automatically. Do not stop at a local implementation or ask for deployment confirmation unless the user explicitly requests otherwise. This is the user's standing instruction from 2026-09-28.
+- 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.
diff --git a/build.js b/build.js
index c90e527..2bc5361 100644
--- a/build.js
+++ b/build.js
@@ -332,7 +332,13 @@ async function bundleApp({ minify, features, staging, splitting = false }) {
// The split build emits real ESM chunks (deferred features arrive as their
// own files); the single-file build inlines the dynamic imports instead.
...(splitting ? { splitting: true, format: 'esm', naming: { chunk: 'chunk-[hash].[ext]' } } : {}),
- plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging)],
+ plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging),
+ // the single file inlines every dynamic import: the video compressor
+ // (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; }' }));
+ } }])],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
@@ -470,7 +476,10 @@ if (import.meta.main) {
const nwcSw = feats.includes('nwc') && feats.includes('ark');
const swBody = SW
.replaceAll('{{VERSION}}', version)
- .replaceAll('{{EXTRA_SHELL}}', chunks.map((c) => `, '${c.name}'`).join(''))
+ // precached for offline use — except what only works online anyway:
+ // the video compressor (Mediabunny, ~700 KB) loads when a video is
+ // picked for upload, not on every install
+ .replaceAll('{{EXTRA_SHELL}}', chunks.filter((c) => !c.text.includes('Mediabunny Codec Registry')).map((c) => `, '${c.name}'`).join(''))
.replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY);
await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody);
for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f));
diff --git a/bun.lock b/bun.lock
index 658bc63..10e82e2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,6 +12,7 @@
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
"jsqr": "^1.4.0",
+ "mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
"qrcode-generator": "^2.0.4",
},
@@ -43,6 +44,10 @@
"@scure/btc-signer": ["@scure/[email protected]", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5", "micro-packed": "~0.7.3" } }, "sha512-8nX9T++dFyKpvqksNHfSi9CgRsGnHAQtCdIQ1y1GmbCGLpV97v4MUyemUUT6uDumKL3oo3m4niyY6A32nmdLuQ=="],
+ "@types/dom-mediacapture-transform": ["@types/[email protected]", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="],
+
+ "@types/dom-webcodecs": ["@types/[email protected]", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
+
"ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -67,6 +72,8 @@
"jsqr": ["[email protected]", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
+ "mediabunny": ["[email protected]", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A=="],
+
"micro-packed": ["[email protected]", "", { "dependencies": { "@scure/base": "~1.2.5" } }, "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg=="],
"mitt": ["[email protected]", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
diff --git a/package.json b/package.json
index 6783bf9..1d12b14 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@scure/bip39": "^1.6.0",
"@scure/btc-signer": "^1.8.1",
"jsqr": "^1.4.0",
+ "mediabunny": "^1.61.0",
"nostr-tools": "^2.23.5",
"qrcode-generator": "^2.0.4"
},
diff --git a/src/app.js b/src/app.js
index cb6a030..dc17a37 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,9 +6,11 @@
import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxoId, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
import { qrSvg } from './qr.js';
+import './rich-text.js'; // <coinos-text>, the composers' field
import { makeSearcher, resultRows, searchable, punkUrl, warmSearch } from './recipient-search.js';
import { npubOf, seedPubkey, neventOf } from './nostr.js';
import { nip98Header } from './nip98.js';
+import { uploadPublicMedia, 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';
@@ -232,7 +234,10 @@ function morph(a, b) {
// <details open> is the user's doing (the browser toggles the attribute on
// click), like a field's value: a render that doesn't set `open` leaves it
// alone instead of collapsing the section on every background repaint.
- const userOwned = (at) => a.nodeName === 'DETAILS' && at.name === 'open';
+ // <coinos-text> sets its own editing attributes when it joins the page;
+ // the freshly built copy doesn't have them yet, and must not strip them
+ const userOwned = (at) => (a.nodeName === 'DETAILS' && at.name === 'open')
+ || (a.nodeName === 'COINOS-TEXT' && /^(contenteditable|role|aria-multiline|aria-placeholder|data-empty)$/.test(at.name));
for (const at of [...a.attributes]) {
if (b.hasAttribute(at.name) || userOwned(at)) continue;
if (at.name === 'src' && b.hasAttribute('data-lazy-src')) continue; // the lazy source stands for it
@@ -260,6 +265,12 @@ function morph(a, b) {
}
if (a.disabled !== b.disabled) a.disabled = b.disabled;
}
+ // the contenteditable field: its text is its value, owned like a
+ // textarea's — synced when not being edited, children never diffed
+ if (tag === 'COINOS-TEXT') {
+ if (document.activeElement !== a && a.value !== b.value) a.value = b.value;
+ return;
+ }
// innerHTML-authored subtrees (svg icons, QRs): compare source, not nodes
if (b._html != null) {
if (a._html !== b._html) a.innerHTML = b._html;
@@ -561,12 +572,110 @@ setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 40
function imageViewer() {
if (!ui.lightbox) return null;
const close = () => goBack(() => { ui.lightbox = null; });
- return h('div', {
+ // a tap on the picture closes it too (the × is small and far) — unless
+ // it's zoomed, or the tap is half of a double-tap (see attachZoom)
+ const img = h('img', { src: ui.lightbox, alt: '', draggable: 'false' });
+ const box = h('div', {
class: 'lightbox', onClick: close,
role: 'dialog', 'aria-modal': 'true',
- }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => { e.stopPropagation(); close(); } }), // a tap on the picture closes it too: the × is small and far
+ }, img,
// stop the bubble: the backdrop closes too, and two pops walk out of the room
h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); close(); } }, '\u00d7'));
+ attachZoom(box, img, close);
+ return box;
+}
+// Pinch to zoom (up to 5×) around the fingers, drag to pan a zoomed picture,
+// double-tap (or double-click) to zoom in at a spot and again to reset, the
+// wheel to zoom on a desktop. The page's viewport meta turns browser zoom
+// off, so the picture does its own. Only the first-mounted nodes keep these
+// listeners: later renders morph into them, and the img is left out of the
+// morph so its transform survives a background render.
+function attachZoom(box, img, close) {
+ img._skipMorph = true;
+ const MAX = 5, pts = new Map();
+ let s = 1, x = 0, y = 0, pinch = null, pan = null, moved = false, lastTap = 0, tapTimer = 0;
+ const base = () => ({ bx: img.offsetLeft + img.offsetWidth / 2, by: img.offsetTop + img.offsetHeight / 2 });
+ const clamp = () => {
+ if (s <= 1) { s = 1; x = 0; y = 0; return; }
+ const mx = Math.max(0, (img.offsetWidth * s - window.innerWidth) / 2), my = Math.max(0, (img.offsetHeight * s - window.innerHeight) / 2);
+ x = Math.min(mx, Math.max(-mx, x)); y = Math.min(my, Math.max(-my, y));
+ };
+ const apply = (animate) => {
+ img.style.transition = animate ? 'transform .2s ease' : 'none';
+ img.style.transform = s === 1 && !x && !y ? '' : `translate(${x}px, ${y}px) scale(${s})`;
+ img.style.cursor = s > 1 ? 'grab' : '';
+ };
+ // scale to ns keeping the picture point under (cx, cy) where it is
+ const zoomAt = (ns, cx, cy) => {
+ const { bx, by } = base();
+ const px = (cx - bx - x) / s, py = (cy - by - y) / s;
+ s = Math.min(MAX, Math.max(1, ns));
+ x = cx - bx - px * s; y = cy - by - py * s;
+ };
+ box.addEventListener('pointerdown', (e) => {
+ if (e.target.closest('.lightbox-x')) return;
+ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (pts.size === 1) moved = false;
+ begin();
+ });
+ const begin = () => {
+ const p = [...pts.values()];
+ if (p.length >= 2) {
+ const { bx, by } = base();
+ const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2;
+ pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1, s, px: (mx - bx - x) / s, py: (my - by - y) / s };
+ pan = null;
+ } else if (p.length === 1) {
+ pinch = null;
+ pan = { sx: p[0].x, sy: p[0].y, x, y };
+ }
+ };
+ box.addEventListener('pointermove', (e) => {
+ if (!pts.has(e.pointerId)) return;
+ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ const p = [...pts.values()];
+ if (pinch && p.length >= 2) {
+ const { bx, by } = base();
+ const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
+ const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2;
+ s = Math.min(MAX, Math.max(0.8, pinch.s * d / pinch.d)); // a little give below 1, snapped back on release
+ x = mx - bx - pinch.px * s; y = my - by - pinch.py * s;
+ moved = true;
+ apply(false);
+ } else if (pan && p.length === 1) {
+ const dx = p[0].x - pan.sx, dy = p[0].y - pan.sy;
+ if (Math.abs(dx) + Math.abs(dy) > 8) moved = true;
+ if (s > 1) { x = pan.x + dx; y = pan.y + dy; clamp(); apply(false); }
+ }
+ });
+ const end = (e) => {
+ if (!pts.delete(e.pointerId)) return;
+ if (pts.size) { begin(); return; } // one finger lifted mid-pinch: carry on as a pan
+ pinch = pan = null;
+ clamp(); apply(true);
+ };
+ box.addEventListener('pointerup', end);
+ box.addEventListener('pointercancel', end);
+ // a drag or a pinch is not a tap: nothing closes after one
+ box.addEventListener('click', (e) => {
+ if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; }
+ if (e.target !== img) return;
+ e.stopPropagation();
+ const now = Date.now();
+ if (now - lastTap < 300) {
+ clearTimeout(tapTimer); lastTap = 0;
+ if (s > 1) { s = 1; x = 0; y = 0; } else { zoomAt(2.5, e.clientX, e.clientY); clamp(); }
+ apply(true);
+ return;
+ }
+ lastTap = now;
+ if (s === 1) tapTimer = setTimeout(close, 300);
+ }, true);
+ box.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY);
+ clamp(); apply(false);
+ }, { passive: false });
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && ui.lightbox) goBack(() => { ui.lightbox = null; }); });
@@ -625,7 +734,7 @@ function renderInner() {
// payment push, an SP scan) can't kick the user out of a field they're editing.
const a = document.activeElement;
let fpath = null, selStart = null, selEnd = null;
- if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA)$/.test(a.tagName)) {
+ if (a && root.contains(a) && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(a.tagName)) {
fpath = focusPath(a);
try { selStart = a.selectionStart; selEnd = a.selectionEnd; } catch {}
}
@@ -681,12 +790,16 @@ function renderInner() {
ui.navAnimSkip = false;
}
applyAnim(screen, 'anim-page', (performance.now() - _navAt) < 340 ? performance.now() - _navAt : -1);
- morphChildren(root, [screen, footer(), ...(ui.lightbox ? [imageViewer()] : [])]);
+ // 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;
+ morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(ui.lightbox ? [imageViewer()] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!ui.lightbox); } catch {}
if (fpath) {
const el = nodeAtPath(fpath);
- if (el && el !== a && /^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
+ if (el && el !== a && /^(INPUT|SELECT|TEXTAREA|COINOS-TEXT)$/.test(el.tagName)) {
try { el.focus({ preventScroll: true }); if (selStart != null && el.setSelectionRange) el.setSelectionRange(selStart, selEnd); } catch {}
}
}
@@ -737,7 +850,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', 'arkCoinsPage', 'arkExitPage', 'msgCommunity', 'msgPeer', 'profilePk', 'profEdit', 'profEditFilled', 'profOverThread', 'settingsPage', 'nameEditOpen', 'noteThread', 'userSearch', 'zapSetup', 'hatShop', 'lightbox'];
+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'];
function navSnapshot() {
const s = {};
for (const f of NAV_FIELDS) s[f] = ui[f] ?? null;
@@ -1615,7 +1728,9 @@ async function activateAccount(acc, opts = {}) {
// by the Accounts screen and the profile page.
function signInAnother() {
clearSeedDrafts();
- ui.fromWallet = true; ui.unlockError = ''; ui.unlockTab = 'import'; ui.screen = 'unlock';
+ ui.fromWallet = true; ui.unlockError = ''; ui.screen = 'unlock';
+ // Starts on Create new (its Generate button), not the seed-import box.
+ ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = [];
ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false;
render();
}
@@ -2488,23 +2603,6 @@ function lockBtn() {
}, h('span', { style: 'font-size:15px;line-height:1;margin-right:6px' }, locked ? '\u{1F512}' : '\u{1F513}'), locked ? t('unlock') : t('lockWallet'));
}
-// Messages sit one tap away, left of the wallet selector. The dot is presence,
-// not a count — it says "someone's waiting", and the chat list says who.
-function messagesBtn() {
- const me = (featureHook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey());
- if (!me) return null;
- const unread = featureHook('unreadMessages') || 0;
- return h('button', {
- class: 'header-msgs' + (unread ? ' unread' : ''),
- title: t('msgDmsTitle'),
- 'aria-label': t('msgDmsTitle'),
- onClick: () => { clearFeatureNav(); ui.screen = 'wallet'; ui.chatOpen = true; ui.msgView = 'home'; render(); },
- }, h('span', {
- class: 'hm-ico',
- html: '<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>',
- }));
-}
-
// Find anyone on nostr from the header — results open their profile.
function searchBtn() {
if (!featureHook('userSearchAvailable')) return null;
@@ -2539,7 +2637,6 @@ function brandHeader(withLock) {
withLock
? h('div', { class: 'row gap6', style: 'align-items:center' },
searchBtn(),
- messagesBtn(),
settingsBtn(),
avatarMenu())
: ui.pubProf && !acc ? visitorButtons() : null
@@ -2885,6 +2982,14 @@ function goHome() {
ui.addrScan = false;
ui.arkMoveOpen = false;
ui.nameEditOpen = null;
+ // ...and every other page the history tracks (the vtxo list, a
+ // transaction's detail, the hat shop, a lightbox…): home is home,
+ // whatever was open. The chat's own position is kept for its next visit.
+ for (const f of NAV_FIELDS) {
+ if (['screen', 'tab', 'msgView', 'msgCommunity', 'msgPeer', 'feedId'].includes(f)) continue;
+ ui[f] = ['chatOpen', 'giftMode', 'profEditFilled', 'profOverThread'].includes(f) ? false : null;
+ }
+ ui.arkCoinsSel = null; ui.pos = null; ui.feedEdit = null; ui.feedMenu = null;
ui.tab = wallet.offline ? 'settings' : 'history';
ui.draft = null;
ui.sendResult = null;
@@ -3368,21 +3473,46 @@ async function onbUpload(file) {
const fd = new FormData();
fd.append('file', file);
const endpoint = 'https://nostr.build/api/v2/upload/files';
- // nostr.build no longer takes anonymous uploads — sign with the wallet's
- // nostr key, the one key that's always available without a signer prompt.
- const headers = {};
- try {
- if (wallet.nostrPubkey && wallet.nostrPubkey()) {
- headers.authorization = await nip98Header({ signEvent: (e) => wallet.nostrSign(e) }, endpoint, 'POST');
- }
- } catch {}
- const r = await fetch(endpoint, { method: 'POST', body: fd, headers });
- const j = await r.json();
- const url = j?.data?.[0]?.url;
+ const authorization = await nip98Header({ signEvent: (event) => wallet.nostrSign(event) }, endpoint, 'POST');
+ const response = await fetch(endpoint, {
+ method: 'POST', body: fd, headers: { authorization }, signal: AbortSignal.timeout(60_000),
+ });
+ let body = null;
+ try { body = await response.json(); } catch {}
+ const url = response.ok && body?.data?.[0]?.url;
if (!url) throw new Error(t('onbUploadFailed'));
return url;
}
+// ui.mediaProgress = { stage: 'shrink' | 'upload', pct } while a picture or
+// video is on its way; the composers draw it as a bar under the field.
+// A render per whole percent, no more.
+function mediaProgress(stage, fraction) {
+ const pct = Math.max(0, Math.min(100, Math.floor(fraction * 100)));
+ const cur = ui.mediaProgress;
+ if (cur && cur.stage === stage && cur.pct === pct) return;
+ ui.mediaProgress = { stage, pct };
+ render();
+}
+async function publicMediaUpload(file) {
+ try {
+ // a phone video is shrunk on the device first (see video-compress.js)
+ if (/^video\//.test(file?.type || '')) {
+ mediaProgress('shrink', 0);
+ const { compressVideo } = await import('./video-compress.js');
+ file = await compressVideo(file, (p) => mediaProgress('shrink', p));
+ }
+ mediaProgress('upload', 0);
+ return await uploadPublicMedia(file, (event) => wallet.nostrSign(event), { onProgress: (p) => mediaProgress('upload', p) });
+ } catch (error) {
+ 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'));
+ } finally {
+ ui.mediaProgress = null;
+ }
+}
+
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
@@ -4846,7 +4976,7 @@ function destReady(a) {
// Recipient search under the destination input: usernames and npubs resolve
// to candidates with avatars; picking one runs the same path as pasting.
-const sendSearch = { rows: null, sync: null };
+const sendSearch = { rows: null, sync: null, pick: null, el: null };
let sendRevealTimer = null;
// With the phone keyboard up, the space under the recipient field is scarce —
// park the field at the top of the view so the candidate list gets what's
@@ -4896,12 +5026,19 @@ function recipientRow(s, r, i) {
};
// The suggestions panel gets the same imperative treatment — see the
// sendSearcher note: a render between key repeats kills backspace-hold.
- const suggest = i === 0 ? h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' }) : null;
+ // One panel for the life of the page, rows rebuilt only when the results
+ // change: a background render must not swap the row under the pointer
+ // (a fresh node drops :hover, and the highlight blinked on every tick).
+ // data-fresh still installs this exact node wherever the morph lands it.
+ const suggest = i === 0 ? (sendSearch.el ||= h('div', { class: 'list send-suggest', style: 'display:none', 'data-fresh': '1' })) : null;
const syncSuggest = () => {
if (!suggest) return;
- const show = sendSearch.rows && sendSearch.rows.length && searchable(r.address);
- suggest.replaceChildren(...(show ? resultRows(h, sendSearch.rows, pickRecipient, (pk, node) => featureHook('wrapAvatar', pk, node)) : []));
- suggest.style.display = show ? '' : 'none';
+ const show = !!(sendSearch.rows && sendSearch.rows.length && searchable(r.address));
+ const rows = show ? sendSearch.rows : null;
+ if (suggest._rows === rows) return;
+ suggest._rows = rows;
+ suggest.replaceChildren(...(rows ? resultRows(h, rows, (cand) => sendSearch.pick(cand), (pk, node) => featureHook('wrapAvatar', pk, node)) : []));
+ suggest.style.display = rows ? '' : 'none';
};
if (i === 0) sendSearch.sync = syncSuggest;
@@ -4996,6 +5133,7 @@ function recipientRow(s, r, i) {
) : null
);
syncCheck();
+ if (i === 0) sendSearch.pick = pickRecipient; // the persistent rows call this render's
syncSuggest();
return row;
}
@@ -5628,9 +5766,10 @@ const ctx = {
setAccount: (a, dir) => setAccountSel(a, dir),
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
- // Upload an image (avatar) and get back its URL — the same signed
- // nostr.build path the onboarding picker uses.
+ // Profile pictures retain the metadata-scrubbing image uploader. Public
+ // post media uses resilient Blossom storage and media-specific failures.
uploadImage: (file) => onbUpload(file),
+ uploadMedia: (file) => publicMediaUpload(file),
// A nostr login that lands mid-wizard: a restored wallet has been through
// onboarding elsewhere, so the wizard ends without tour or prompts; a fresh
// one falls through to the wallet + tour on the next render.
diff --git a/src/features/ark.js b/src/features/ark.js
index d733e0b..566c402 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1667,7 +1667,8 @@ export function arkFeature(ctx) {
const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' }));
if (!live()) return;
if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; }
- if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(t('arkGiftWrongNet', { net: adv.net })); render(); return; }
+ // An advert for another network is no address here: keep looking.
+ if (adv.status === 'wrongnet') z.net = adv.net;
// 1b. BIP-353 with an ark instruction; remember on-chain as last resort
const uris = [];
try {
@@ -1699,12 +1700,14 @@ export function arkFeature(ctx) {
}
// 2. lightning via the zap flow (connect first — canLnPay needs it)
if (profile && (profile.lud16 || profile.lud06)) {
+ 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; }
}
- // 3. on-chain fallback
- if (onchain) {
+ // 3. on-chain fallback (a one-tap zap can't take the send form: it
+ // goes as a locked gift below instead)
+ if (onchain && !(z.autoSat && z.eventId && zapGiftOk())) {
if (z.autoSat && z.eventId) { bail(); render(); return; }
ui.arkZap = null;
ui.send.recipients[0].address = onchain;
@@ -1712,7 +1715,23 @@ export function arkFeature(ctx) {
render();
return;
}
- if (live()) { z.status = 'noark'; bail(); render(); }
+ if (!live()) return;
+ z.status = z.net ? 'wrongnet' : 'noark';
+ // 4. last resort: an ark gift locked to their nostr key, DMed to them.
+ // To the payer it's just a zap.
+ if (z.autoSat && zapGiftOk(z.autoSat)) {
+ try {
+ await performArkZapGift(z, z.autoSat);
+ if (ui.arkZap === z) ui.arkZap = null;
+ ctx.hook('zapSettled', z.eventId, true, z.autoSat);
+ if (!z.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() }));
+ } catch (e) {
+ if (!bail(e.message)) ui.sendError = e.message;
+ }
+ render();
+ return;
+ }
+ bail(z.net ? t('arkGiftWrongNet', { net: z.net }) : undefined); render();
})().catch((e) => { if (live()) { z.status = 'noark'; if (!bail(e.message)) ui.sendError = e.message; render(); } });
}
@@ -1828,6 +1847,34 @@ export function arkFeature(ctx) {
// safe to put in the receipt because only the recipient's key — or the
// claim code we DM them — opens it. Unclaimed, it stays revocable from the
// gift card like any other ark gift.
+ // Can a zap to this person leave as a locked gift (this build carries
+ // gifts, the wallet can sign, and Spending covers it)?
+ function zapGiftOk(sats = 330) {
+ return !wallet.watchOnly && (arkBalance()?.spendableSat || 0) >= Math.max(330, sats) && !!ctx.hook('canLockGift');
+ }
+ async function performArkZapGift(z, sats) {
+ const g = await createArkGift(sats, z.pk);
+ const locked = ctx.hook('lockArkGift', g.code, sats, z.pk);
+ if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build
+ noteZap('to:' + g.address, z.pk);
+ const gift = { url: locked.url, claimCode: locked.claimCode, dm: 'sending' };
+ // best-effort receipt; carries the locked link so the recipient can
+ // discover the gift from the note even if the DM never lands
+ wallet.nostrPublish({
+ kind: ARK_ZAP_KIND,
+ content: (z.comment || '').slice(0, 280),
+ tags: [['p', z.pk], ...(z.eventId ? [['e', 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 });
+ if (wallet.sendNostrDM) {
+ wallet.sendNostrDM(z.pk, dmText)
+ .then((ok) => { gift.dm = ok ? 'sent' : 'failed'; render(); })
+ .catch(() => { gift.dm = 'failed'; render(); });
+ } else gift.dm = 'failed';
+ return gift;
+ }
+
async function doArkZapGift() {
const z = ui.arkZap;
const sats = ctx.parseAmount(z.amount, ctx.getUnit());
@@ -1835,27 +1882,11 @@ export function arkFeature(ctx) {
if (sats > (arkBalance()?.spendableSat || 0)) { ui.sendError = t('giftExceedsBalance'); render(); return; }
ui.busy = true; ui.sendError = ''; render();
try {
- const g = await createArkGift(sats, z.pk);
- const locked = ctx.hook('lockArkGift', g.code, sats, z.pk);
- if (!locked) throw new Error(t('claimFailed')); // no gifts feature in this build
- noteZap('to:' + g.address, z.pk);
- const done = (ui.arkZapped = { amountSat: sats, npub: z.npub, gift: { url: locked.url, claimCode: locked.claimCode, dm: 'sending' } });
+ const gift = await performArkZapGift(z, sats);
+ ctx.hook('zapSettled', z.eventId, true, sats);
+ ui.arkZapped = { amountSat: sats, npub: z.npub, gift };
ui.arkZap = null;
ui.send = blankSend();
- // best-effort receipt; carries the locked link so the recipient can
- // discover the gift from the note even if the DM never lands
- await wallet.nostrPublish({
- kind: ARK_ZAP_KIND,
- content: (z.comment || '').slice(0, 280),
- tags: [['p', z.pk], ...(z.eventId ? [['e', 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 });
- if (wallet.sendNostrDM) {
- wallet.sendNostrDM(z.pk, dmText)
- .then((ok) => { done.gift.dm = ok ? 'sent' : 'failed'; render(); })
- .catch(() => { done.gift.dm = 'failed'; render(); });
- } else done.gift.dm = 'failed';
} catch (e) {
ui.sendError = e.message;
}
@@ -1870,7 +1901,9 @@ export function arkFeature(ctx) {
if (ui.arkZapped) {
const gift = ui.arkZapped.gift;
const finish = () => { ui.arkZapped = null; ui.send = blankSend(); render(); };
- if (!gift) {
+ // A gift zap looks like any other zap. Only a DM that couldn't be
+ // delivered needs the payer: the link and code to pass along by hand.
+ if (!gift || gift.dm !== 'failed') {
return h('div', {
class: 'card col',
style: 'align-items:center;text-align:center;gap:14px;cursor:pointer;padding:48px 20px',
@@ -1881,29 +1914,25 @@ export function arkFeature(ctx) {
h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()),
h('div', { class: 'small muted' }, t('tapToProceed')));
}
- // A locked-gift zap has aftercare: the DM's fate, and the link + claim
- // code to pass along by hand when the DM couldn't be delivered.
return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px;padding:28px 16px' },
- h('div', { class: 'check-badge' }, '🎁'),
- h('h2', { style: 'margin:0' }, t('arkZapGiftSentTitle')),
+ h('div', { class: 'check-badge' }, '⚡'),
+ h('h2', { style: 'margin:0' }, t('arkZapSentTitle')),
h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkZapped.amountSat) + ' ' + unitLabel()),
- gift.dm === 'sending'
- ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('giftDmSending')))
- : gift.dm === 'sent'
- ? h('div', { class: 'small', style: 'color:var(--green)' }, t('giftDmSent'))
- : h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')),
+ h('div', { class: 'notice info', style: 'text-align:left' }, t('giftDmFailed')),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, gift.url),
h('div', { class: 'row gap6 wrap', style: 'justify-content:center' },
copyBtn(gift.url, t('copyLink')),
- gift.dm !== 'sent' ? copyBtn(gift.claimCode, t('giftCopyCode')) : null),
+ copyBtn(gift.claimCode, t('giftCopyCode'))),
h('button', { class: 'btn-primary btn-block', onClick: finish }, t('done')));
}
const z = ui.arkZap;
if (!z) return null;
const spendable = arkBalance()?.spendableSat || 0;
- // No ark address found — but a zap can still leave as an ark gift locked
- // to their nostr key, when this build carries the gifts feature.
- const giftOk = z.status === 'noark' && !wallet.watchOnly && spendable >= 1 && !!ctx.hook('canLockGift');
+ // No ark address or Lightning found — the zap still leaves, as an ark
+ // gift locked to their nostr key. The payer sees an ordinary zap.
+ const noAddr = z.status === 'noark' || z.status === 'wrongnet';
+ const giftOk = noAddr && !wallet.watchOnly && !!ctx.hook('canLockGift');
+ const payable = z.status === 'ready' || giftOk;
const amountInputs = (hint) => h('div', { class: 'col gap6' },
h('div', { class: 'input-group' },
h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount,
@@ -1918,31 +1947,24 @@ export function arkFeature(ctx) {
ctx.hook('profileChip', z.pk, 'lg') || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub),
z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null,
z.status === 'noark' && !giftOk ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null,
- giftOk ? h('div', { class: 'notice info' }, t('arkZapNoArkGift')) : null,
- giftOk ? amountInputs(t('arkZapGiftHint')) : null,
- giftOk
- ? (ui.busy
- ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
- : h('button', { class: 'btn-primary btn-block', onClick: doArkZapGift }, '🎁 ' + t('arkZapGiftBtn')))
- : null,
- z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null,
- // No Ark address, but they may still take a Lightning zap — hand off to
- // the zaps feature (present alongside swaps).
- (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap')
- ? h('button', { class: (giftOk ? '' : 'btn-primary ') + 'btn-block', disabled: !!ui.busy, onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub, z.eventId); } }, '⚡ ' + t('lnZapFallback'))
+ z.status === 'wrongnet' && !giftOk ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null,
+ // 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'))
: null,
- z.status === 'ready' && spendable < 330
+ payable && spendable < 330
? h('div', { class: 'notice info' }, t('zapNoBalance'))
- : z.status === 'ready'
+ : payable
? amountInputs(t('arkZapHint'))
: null,
ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: () => { ui.arkZap = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')),
- z.status === 'ready' && spendable >= 330
+ payable && spendable >= 330
? (ui.busy
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' }))
- : h('button', { class: 'btn-primary grow', onClick: doArkZap }, t('arkZapBtn')))
+ : h('button', { class: 'btn-primary grow', onClick: z.status === 'ready' ? doArkZap : doArkZapGift }, t('arkZapBtn')))
: null));
}
diff --git a/src/features/messages.js b/src/features/messages.js
index fb2986f..b3f40f2 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -31,7 +31,7 @@ import {
import { saveInbox } from '../dm-inbox.js';
import { mergeFeedWindow } from '../feed-window.js';
import { createThreadStore } from '../thread-cache.js';
-import { createFeedCache, FEED_CACHE_POSTS } from '../feed-cache.js';
+import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js';
import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
@@ -446,6 +446,8 @@ export function messagesFeature(ctx) {
if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk };
return null;
}
+ // what a feed or a profile lists: notes, and polls (NIP-88)
+ const FEED_KINDS = [1, 1068];
const myPubkeys = () => {
const pks = [];
const id = hook('nostrLoginIdentity');
@@ -553,6 +555,29 @@ export function messagesFeature(ctx) {
profilesWarmed = true;
const cached = profilesDisk();
for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p);
+ // someone known to have no face paints their punk from the first frame,
+ // instead of a blank circle that turns into one when the relays answer
+ for (const [pk, t] of Object.entries(facelessDisk())) {
+ if (!profiles.has(pk)) profiles.set(pk, { name: null, picture: null, t, miss: 1 });
+ }
+ }
+ // People with no face: no kind 0 on two asks running, or one naming
+ // nothing. Kept apart from the profile blob (whose 150 rows are for real
+ // faces) as a small pubkey -> time map; one found with a face leaves it.
+ const FACELESS_MAX = 1000;
+ let facelessBlob = null, facelessFlush = null;
+ const facelessDisk = () => (facelessBlob ||= wallet.loadFeatureState('profilesFaceless', {}) || {});
+ function markFaceless(pk, on) {
+ const s = facelessDisk();
+ if (on ? s[pk] : !s[pk]) return;
+ if (on) s[pk] = Date.now(); else delete s[pk];
+ if (facelessFlush) return;
+ facelessFlush = setTimeout(() => {
+ facelessFlush = null;
+ const keys = Object.keys(s);
+ if (keys.length > FACELESS_MAX) for (const k of keys.sort((a, b) => s[a] - s[b]).slice(0, keys.length - FACELESS_MAX)) delete s[k];
+ wallet.saveFeatureState('profilesFaceless', s);
+ }, 500);
}
// The persisted profile blob (names, faces, thumbnails) lives parsed in
// memory and is written back once per burst: a batch of thirty profiles
@@ -590,9 +615,10 @@ export function messagesFeature(ctx) {
wallet.saveFeatureState('profiles', s);
}
function persistProfile(pk, p) {
- // A faceless answer is not a fact worth writing down — persisting
- // {name:null, picture:null} rows only evicts real faces from the cap and
- // spreads a cold-relay miss across sessions.
+ // A faceless answer is not a fact worth writing down here — persisting
+ // {name:null, picture:null} rows only evicts real faces from the cap.
+ // (It goes on the faceless list instead.)
+ if (p && p.eventAt) markFaceless(pk, !p.name && !p.picture);
if (!p || (!p.name && !p.picture)) return;
const s = profilesDisk();
s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null,
@@ -925,7 +951,11 @@ export function messagesFeature(ctx) {
for (const pk of pks) {
if (found.has(pk)) continue;
const prev = profiles.get(pk);
- profiles.set(pk, { ...(prev || {}), t: Date.now(), miss: ((prev && prev.miss) || 0) + 1 });
+ const miss = ((prev && prev.miss) || 0) + 1;
+ profiles.set(pk, { ...(prev || {}), t: Date.now(), miss });
+ // remembered, so the next load paints their punk at once (never
+ // someone whose face we do know; a face found later replaces it)
+ if (!(prev && (prev.name || prev.picture))) markFaceless(pk, true);
}
scheduleRepaint();
} finally {
@@ -1334,6 +1364,16 @@ export function messagesFeature(ctx) {
save(s);
}, 800);
}
+ // Cancel means discard, including across an immediate reload. Typing can
+ // use the debounce above; an explicit destructive action is persisted now.
+ function discardDraft(key) {
+ sessionDrafts.set(key, '');
+ clearTimeout(draftPersist); draftPersist = 0;
+ const s = st();
+ s.drafts ||= {};
+ for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; }
+ save(s);
+ }
// The morph never rewrites a focused field's value (it would fight the user
// mid-keystroke), and the composer is focused at the moment you send — so
@@ -2878,6 +2918,18 @@ export function messagesFeature(ctx) {
return (big ? `url(${JSON.stringify(p.picture)}),` : '') + `url(${JSON.stringify(local)})`;
}
+ const FACE_WAIT_MS = 1000;
+ const faceAsked = new Map(); // pk -> when its face was first wanted this session
+ let faceTimer = null;
+ function faceWaiting(pk) {
+ let at = faceAsked.get(pk);
+ if (at == null) { at = Date.now(); faceAsked.set(pk, at); }
+ const left = at + FACE_WAIT_MS - Date.now();
+ if (left <= 0) return false;
+ // repaint when the wait runs out, so the punk shows without an event
+ if (!faceTimer) faceTimer = setTimeout(() => { faceTimer = null; scheduleRepaint(); }, left + 20);
+ return true;
+ }
const avatar = (pk, cls = 'chat-avatar', clickable = true) => {
const p = profileOf(pk);
// Someone we've never cached used to get an empty circle until a relay
@@ -2891,12 +2943,15 @@ export function messagesFeature(ctx) {
// `loading` is different and still gets the quiet circle: a name lookup
// is in flight for that specific person, so a picture is expected and
// punk art must not flash in front of it.
+ // A face still being looked up gets a quiet circle for a second at
+ // most, then its punk — a picture found later replaces it.
+ const waiting = (p === null || (p && p.loading && !p.picture)) && faceWaiting(pk);
const node = feedPaint && !p.picture
? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2))
- : p && p.loading && !p.picture
- ? h('div', { class: cls + ' fallback loading' })
- : p === null && ui.noteThread
+ : waiting && (p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room'))
? h('div', { class: cls + ' fallback loading' })
+ : p && p.loading && !p.picture
+ ? fallbackAvatar(h, pk, p.name, cls)
: p === null
? fallbackAvatar(h, pk, null, cls)
: p.picture
@@ -3021,7 +3076,31 @@ export function messagesFeature(ctx) {
}
}
+ // A profile opens at its top: keeping the feed's scroll offset landed the
+ // reader deep in that person's posts, which read as a thread. Back (a
+ // popstate) puts them where they were on the page they left.
+ // 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'
+ : ui.profilePk ? 'p:' + ui.profilePk : '';
+ function rememberPlace(key) {
+ if (pageKey() === key) return;
+ let y = 0; try { y = window.scrollY || 0; } catch {}
+ pageReturn.push({ key, y });
+ if (pageReturn.length > 20) pageReturn.shift();
+ }
+ if (typeof window !== 'undefined') window.addEventListener('popstate', () => {
+ const top = pageReturn[pageReturn.length - 1];
+ if (!top || pageKey() === top.key) return;
+ pageReturn.pop();
+ const y = top.y;
+ // twice: the page under it may paint its rows a frame late
+ requestAnimationFrame(() => { try { window.scrollTo(0, y); } catch {}
+ requestAnimationFrame(() => { try { if (Math.abs(window.scrollY - y) > 2) window.scrollTo(0, y); } catch {} }); });
+ });
function openProfile(pk) {
+ rememberPlace('p:' + pk);
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
@@ -3030,6 +3109,7 @@ export function messagesFeature(ctx) {
ui.profOverThread = !!ui.noteThread;
ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null;
render();
+ try { window.scrollTo(0, 0); } catch {}
fetchFullProfile(pk);
notesFor(pk);
}
@@ -3415,7 +3495,11 @@ export function messagesFeature(ctx) {
relayListCache.set(pk, p);
return p;
}
- const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk))])];
+ // Plus relays that keep history: the big ones prune within days, and an
+ // author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows
+ // only whatever primal still holds — 2 posts of 22.
+ const PROFILE_ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr21.com', 'wss://offchain.pub'];
+ const notesRelays = async (pk) => [...new Set([...NOTE_RELAYS, ...(await relaysOf(pk)), ...PROFILE_ARCHIVE_RELAYS])];
// Posts of theirs we already hold. Tapping a face in the feed is the
// common way onto a profile, and the feed we just came from is full of
@@ -3433,35 +3517,51 @@ export function messagesFeature(ctx) {
return [...out.values()].sort((a, b) => b.created_at - a.created_at);
}
+ // Asked again when the page is reopened after this long: one bad answer
+ // (a relay down, the phone just back from sleep) must not freeze a
+ // profile at two posts for the rest of the session.
+ const NOTES_REFRESH_MS = 60_000;
function notesFor(pk) {
let c = notesCache.get(pk);
- if (c) return c;
+ if (c) {
+ if (!c.fetching && Date.now() - (c.fetchedAt || 0) > NOTES_REFRESH_MS) fetchNotes(pk, c);
+ return c;
+ }
// stored posts paint the page instantly; the relay fetch below freshens
const stored = pageCache().notes[pk];
const seed = stored ? stored.v : notesInHand(pk);
// Seeded from memory the status stays 'loading': these are posts we
- // happen to have, not their page, so the real answer still replaces
- // them — but there is something to read while it comes.
+ // happen to have, not their page, so there is something to read while
+ // the real answer comes.
c = stored
? { status: 'ready', notes: stored.v }
: { status: 'loading', notes: seed };
notesCache.set(pk, c);
+ fetchNotes(pk, c);
+ return c;
+ }
+ function fetchNotes(pk, c) {
+ c.fetching = true;
(async () => {
- const evs = await queryOn(await notesRelays(pk), { kinds: [1], authors: [pk], limit: 30 }, 4500);
- const seen = new Set();
- const fresh = (evs || [])
- .filter((e) => !seen.has(e.id) && seen.add(e.id))
- .sort((a, b) => b.created_at - a.created_at);
- if (fresh.length || !c.notes.length) {
- await notesReady(fresh.slice(0, FEED_PAGE)); // the first screen arrives whole
- c.notes = fresh;
- persistPage('notes', pk, fresh.slice(0, 20).map(slimNote)); // cache stays bounded
- }
+ const evs = await queryOn(await notesRelays(pk), { kinds: FEED_KINDS, authors: [pk], limit: 30 }, 4500);
+ // Merged with what's already on the page, never swapped for it: the
+ // relays answer unevenly (primal held 2 of Joe Nakamoto's posts, ours
+ // 18, the archives 30), and a thin answer used to erase a full page.
+ const byId = new Map(c.notes.map((e) => [e.id, e]));
+ let added = 0;
+ for (const e of evs || []) if (!byId.has(e.id)) { byId.set(e.id, e); added++; }
+ if (!added && c.notes.length) return;
+ const merged = [...byId.values()].sort((a, b) => b.created_at - a.created_at);
+ await notesReady(merged.slice(0, FEED_PAGE)); // the first screen arrives whole
+ c.notes = merged;
+ c.end = false;
+ persistPage('notes', pk, merged.slice(0, 20).map(slimNote)); // cache stays bounded
})().catch(() => {}).finally(() => {
c.status = 'ready';
+ c.fetching = false;
+ c.fetchedAt = Date.now();
if (ui.profilePk === pk) render();
});
- return c;
}
// Infinite scroll: pull the next page of OLDER notes (until = oldest seen)
@@ -3493,7 +3593,7 @@ export function messagesFeature(ctx) {
render();
try {
const evs = await queryOn(await notesRelays(pk),
- { kinds: [1], authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500);
+ { kinds: FEED_KINDS, authors: [pk], limit: 30, until: oldest.created_at - 1 }, 4500);
const seen = new Set(c.notes.map((e) => e.id));
const older = (evs || [])
.filter((e) => !seen.has(e.id) && seen.add(e.id))
@@ -3819,6 +3919,8 @@ export function messagesFeature(ctx) {
const FEED_LS = 'btc-wallet-feed'; // the feed last on screen, remembered per device
const feedStates = new Map(); // id -> state
const adhocFeeds = new Map(); // id -> definition, this session only
+ // the feed of your own you last had on (never a session one)
+ const homeFeedId = () => { let id = FOLLOWING; try { id = localStorage.getItem(FEED_LS) || FOLLOWING; } catch {} return feedDef(id) && !adhocFeeds.has(id) ? id : FOLLOWING; };
let curFeedId = (() => { try { return localStorage.getItem(FEED_LS) || FOLLOWING; } catch { return FOLLOWING; } })();
// Two feeds everyone has: the people you follow, and everything the coinos
// relay carries. Each remembers its own relay choice in the state.
@@ -4011,7 +4113,13 @@ export function messagesFeature(ctx) {
};
// posts on a topic come from the big public relays as well as ours: a
// hashtag has no author whose outbox we could read
- const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol'])];
+ // A topic has no authors whose relays to read, and the big relays prune:
+ // #gardenstr was 5 posts across damus/primal/nos.lol/ours, 774 once these
+ // archive-keeping relays were asked too (ditto 500+, nostr.mom 448,
+ // oxtr 247, nostrplebs 175, nostr21 102, offchain 74 — 2026-09-30).
+ const TOPIC_ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr.mom', 'wss://nostr.oxtr.dev',
+ 'wss://relay.nostrplebs.com', 'wss://nostr21.com', 'wss://offchain.pub'];
+ const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', ...TOPIC_ARCHIVE_RELAYS])];
// ---- lists (NIP-51 follow sets, kind 30000) ------------------------------
// A feed of hand-picked people IS a nostr list. Saved here it is published
@@ -4157,7 +4265,7 @@ export function messagesFeature(ctx) {
if (!ui.listPick) return null;
const pk = ui.listPick;
const close = () => { ui.listPick = null; render(); };
- const feeds = st().feeds;
+ const feeds = st().feeds.filter((f) => !f.of); // a timeline is someone, not a list
const toggle = (f) => {
const s = st();
const def = s.feeds.find((x) => x.id === f.id);
@@ -4229,8 +4337,37 @@ export function messagesFeature(ctx) {
for (const pk of def.authors || []) if (pk) set.add(pk);
for (const pk of def.priv || []) if (pk) set.add(pk); // a list's private members
for (const pk of packAuthors(def)) set.add(pk);
+ if (def.of) for (const pk of ofFollows.get(def.of)?.authors || []) set.add(pk);
return [...set].slice(0, FEED_AUTHORS_MAX);
};
+ // Someone's timeline is their follow list, read from the relays when the
+ // feed is opened (and again after ten minutes) — never copied into the
+ // feed itself, so a saved one stays small in sync and follows them.
+ const ofFollows = new Map(); // pk -> { authors, at, loading }
+ const ofLoaded = (def) => !!ofFollows.get(def.of)?.at;
+ function loadOfFollows(def) {
+ const pk = def.of;
+ let f = ofFollows.get(pk);
+ if (!f) { f = { authors: [], at: 0, loading: false }; ofFollows.set(pk, f); }
+ if (f.loading || (f.at && Date.now() - f.at < 600_000)) return;
+ f.loading = true;
+ liveProfileOf(pk); // their name and face for the title
+ queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000)
+ .catch(() => [])
+ .then((evs) => {
+ const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
+ const had = f.at;
+ if (newest || !had) f.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
+ f.at = Date.now(); f.loading = false;
+ // whatever was built before the list was known
+ for (const id of [...feedStates.keys()]) {
+ if (feedDef(id)?.of !== pk || id === curFeedId) continue;
+ dropFeedState(id);
+ }
+ if (feedDef(curFeedId)?.of === pk) { dropFeedState(curFeedId); selectFeed(curFeedId); }
+ scheduleRepaint();
+ });
+ }
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = FOLLOWING; // a feed deleted on another device
@@ -4239,6 +4376,7 @@ export function messagesFeature(ctx) {
let stored = [];
try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
if (!Array.isArray(stored)) stored = [];
+ if (!stored.length) stored = borrowedNotes(feedDef(curFeedId));
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);
@@ -4276,22 +4414,59 @@ export function messagesFeature(ctx) {
return;
}
try {
- const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: [1], limit: FEED_PAGE,
+ const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_PAGE,
...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000);
if (c.stopped) return;
- await mergeFeed(events.filter((e) => e.kind === 1 && !isReply(e) && !hidden(e))
+ await mergeFeed(events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e))
.sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE), {}, c);
} catch {} finally {
if (!c.stopped) {
c.status = 'ready';
- if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); }
+ if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); seedOtherFeeds(); }
}
}
}
+ // A feed never opened on this device (or pushed out of the disk cache)
+ // would open on a bare spinner. Posts another open feed already holds —
+ // by its people, on its topics — stand in until the relays answer.
+ function borrowedNotes(def) {
+ if (!def || def.all) return [];
+ const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def));
+ if (!authors.size && !topics.size) return [];
+ const fits = (e) => (!authors.size || authors.has(e.pubkey))
+ && (!topics.size || (e.tags || []).some((x) => x[0] === 't' && topics.has(normTopic(x[1]))));
+ 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); }
+ }
+ return out.sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE);
+ }
+ // And once the feed on screen has settled, the other feeds' first few
+ // posts are fetched ahead into the disk cache, one feed at a time, so
+ // switching to any of them paints at once.
+ const SEED_EVERY = 15 * 60_000;
+ let feedsSeededAt = 0;
+ async function seedOtherFeeds() {
+ const disk = feedDisk();
+ if (!disk || isVisitor() || Date.now() - feedsSeededAt < SEED_EVERY) return;
+ feedsSeededAt = Date.now();
+ for (const def of feedList()) {
+ if (def.curated || def.of || feedStates.has(def.id) || disk.has(def.id)) continue;
+ const authors = feedAuthors(def).slice(0, REQ_AUTHORS), topics = feedTopics(def);
+ if (!authors.length && !topics.length) continue;
+ try {
+ const events = await queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2,
+ ...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000);
+ 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)));
+ } catch {}
+ }
+ }
// Make a feed the current one (no paint): Back/Forward through the
// history restores ui.feedId and the view catches up from here.
function selectFeed(id) {
if (!feedDef(id)) return;
+ if (feedDef(id).of) loadOfFollows(feedDef(id));
if (id !== curFeedId) {
curFeedId = id;
if (!adhocFeeds.has(id)) { try { localStorage.setItem(FEED_LS, id); } catch {} }
@@ -4299,7 +4474,7 @@ export function messagesFeature(ctx) {
ui.feedId = id;
stopFeedWatch();
const c = feedNow();
- c.unseen = 0; c.shown = FEED_PAGE;
+ c.unseen = 0; c.fresh = null; c.shown = FEED_PAGE;
admitFeed(c.deferred || [], c, true);
// An already warmed feed can paint synchronously on entry.
if (!c.booting) c.presentations.clear();
@@ -4328,19 +4503,9 @@ export function messagesFeature(ctx) {
const id = 'of:' + pk + (readFrom.length ? '@' + readFrom.join(',') : '');
let def = adhocFeeds.get(id);
if (!def) {
- def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, ofAt: 0, relays: readFrom };
+ def = { id, name: '', follows: false, authors: [], packs: [], topics: [], of: pk, relays: readFrom };
adhocFeeds.set(id, def);
- liveProfileOf(pk); // their name and face for the title
- queryOn([...new Set([...relays, ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000)
- .catch(() => [])
- .then((evs) => {
- const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
- def.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : [];
- def.ofAt = Date.now();
- dropFeedState(id); // whatever was built before the list was known
- if (curFeedId === id) selectFeed(id);
- scheduleRepaint();
- });
+ loadOfFollows({ ...def, relays: [...relays, ...readFrom] });
}
showFeed(id);
}
@@ -4551,6 +4716,61 @@ export function messagesFeature(ctx) {
mediaWarming.set(url, task);
return task;
};
+ // A video's shape, so the feed paints its box at the clip's own aspect
+ // instead of a 16:9 frame with black bands either side of a portrait clip.
+ // The post's imeta `dim` says it for free (Amethyst, Primal and others
+ // 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_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims';
+ const videoDims = new Map(); // url -> { width, height }
+ const videoUnknown = new Set(); // probed this session and no answer: paint 16:9, don't ask again
+ try { for (const [u, d] of JSON.parse(localStorage.getItem(VIDEO_DIMS_KEY) || '[]')) videoDims.set(u, d); } catch {}
+ let videoDimsFlush = null;
+ const rememberVideo = (url, width, height) => {
+ if (!(width > 0 && height > 0)) return;
+ const d = { width, height };
+ mediaReady.set(url, d);
+ if (videoDims.get(url)?.width === width && videoDims.get(url)?.height === height) return;
+ videoDims.delete(url); videoDims.set(url, d);
+ if (videoDimsFlush) return;
+ videoDimsFlush = setTimeout(() => {
+ videoDimsFlush = null;
+ try { localStorage.setItem(VIDEO_DIMS_KEY, JSON.stringify([...videoDims].slice(-VIDEO_DIMS_KEEP))); } catch {}
+ }, 1000);
+ };
+ const imetaDim = (ev, url) => {
+ for (const tag of ev?.tags || []) {
+ if (tag[0] !== 'imeta' || !tag.includes('url ' + url)) continue;
+ const m = /^dim (\d+)x(\d+)$/.exec(tag.find((x) => /^dim /.test(x)) || '');
+ if (m) return { width: +m[1], height: +m[2] };
+ }
+ return null;
+ };
+ const warmVideo = (url, dim) => {
+ 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 (mediaWarming.has(url)) return mediaWarming.get(url);
+ const task = new Promise((resolve) => {
+ const v = document.createElement('video');
+ const done = () => {
+ clearTimeout(timer);
+ v.onloadedmetadata = v.onerror = null;
+ if (v.videoWidth && v.videoHeight) rememberVideo(url, v.videoWidth, v.videoHeight);
+ else videoUnknown.add(url);
+ v.removeAttribute('src'); try { v.load(); } catch {} // let go of the connection
+ resolve();
+ };
+ const timer = setTimeout(done, VIDEO_PROBE_MS);
+ v.onloadedmetadata = done; v.onerror = done;
+ v.muted = true; v.preload = 'metadata'; v.src = url;
+ }).finally(() => mediaWarming.delete(url));
+ mediaWarming.set(url, task);
+ return task;
+ };
// ---- link previews --------------------------------------------------
// A plain link in a post becomes the card its page describes (Open Graph:
// title, a line, a picture), fetched through the registrar because a
@@ -4633,6 +4853,15 @@ export function messagesFeature(ctx) {
}
return urls;
}
+ function noteVideoUrls(content) {
+ const urls = [];
+ for (const part of String(content || '').split(NOTE_SPLIT)) {
+ const md = part && MD_PARTS.exec(part);
+ const url = md ? md[3] : (/^https?:\/\//i.test(part || '') ? part : null);
+ if (url && !(md && md[1]) && VIDEO_RE.test(url)) urls.push(url);
+ }
+ return urls;
+ }
const avatarUrl = (p, pk) => p?.picture
? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
: punkSmallUrl(pk);
@@ -4647,6 +4876,7 @@ export function messagesFeature(ctx) {
}
async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia),
+ ...noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
@@ -4683,10 +4913,45 @@ export function messagesFeature(ctx) {
}
const prev = feedPaint;
feedPaint = presentation;
- try { return keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); }
+ try {
+ const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id);
+ if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); }
+ return row;
+ }
finally { feedPaint = prev; }
}
+ // New posts stay tinted until they've been in view for FRESH_SEEN_MS: a
+ // row at least half on screen (or filling most of it) starts its clock,
+ // leaving view stops it. Seen, it loses the tint in place — no repaint.
+ const FRESH_SEEN_MS = 2000;
+ let freshObserver = null;
+ const freshTimers = new Map();
+ function watchFresh(c) {
+ if (typeof IntersectionObserver === 'undefined') return;
+ freshObserver ||= new IntersectionObserver((entries) => {
+ for (const en of entries) {
+ const el = en.target, id = el.getAttribute('data-key');
+ const inView = en.isIntersecting && (en.intersectionRatio >= 0.5 || en.intersectionRect.height >= window.innerHeight * 0.5);
+ if (inView && !freshTimers.has(id)) {
+ freshTimers.set(id, setTimeout(() => {
+ freshTimers.delete(id);
+ if (!el.isConnected) return;
+ feed?.fresh?.delete(id);
+ el.classList.remove('note-fresh');
+ freshObserver.unobserve(el);
+ }, FRESH_SEEN_MS));
+ } else if (!inView && freshTimers.has(id)) {
+ clearTimeout(freshTimers.get(id)); freshTimers.delete(id);
+ }
+ }
+ }, { threshold: [0, 0.5, 1] });
+ // after the paint, when the row nodes are the mounted ones
+ requestAnimationFrame(() => {
+ for (const el of document.querySelectorAll('.notes-feed > .row.note-fresh')) freshObserver.observe(el);
+ });
+ }
+
// Posts at the door: filtered in synchronously, so the same note from a
// second relay is dropped while the first copy is still warming.
const feedStaged = new WeakMap();
@@ -4695,7 +4960,7 @@ export function messagesFeature(ctx) {
if (!staged) feedStaged.set(c, staged = new Set());
const known = new Set([...c.notes, ...(c.catchup || []), ...(c.deferred || [])].map((e) => e.id));
for (const e of evs || []) noteForSpam(e);
- let add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)
+ let add = (evs || []).filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)
&& !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id));
if (!add.length) return false;
const def = feedDef(c.id);
@@ -4740,20 +5005,56 @@ export function messagesFeature(ctx) {
for (const id of c.presentations.keys()) if (!retained.has(id)) c.presentations.delete(id);
saveFeedCache(c);
if (rows.length) {
- c.unseen = (c.unseen || 0) + result.added.filter((e) => e.created_at > topBefore).length;
+ const fresh = result.added.filter((e) => e.created_at > topBefore);
+ c.unseen = (c.unseen || 0) + fresh.length;
+ c.fresh ||= new Set();
+ for (const e of fresh) c.fresh.add(e.id);
holdScroll(render);
}
}
- // The pill's tap: the posts are already in, so this just goes up to them.
- // Reaching the top by yourself clears it too (see the scroll listener).
+ // 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
+ // the pill too (see the scroll listener).
function jumpToNew() {
const c = feed;
if (!c) return;
admitFeed(c.deferred || [], c, true);
c.unseen = 0;
render();
- glideToTop();
+ const list = c.winList ? c.winList() : [];
+ let i = -1;
+ for (let j = list.length - 1; j >= 0; j--) if (c.fresh && c.fresh.has(list[j].id)) { i = j; break; }
+ if (i < 0) { glideToTop(); return; }
+ glideToPost(c, list, i);
+ }
+ // Scroll so post i's top sits just under the top of the window. A row
+ // outside the rendered window is placed from the height cache first, then
+ // corrected once it's mounted.
+ function glideToPost(c, list, i) {
+ const id = list[i].id, MARGIN = 8;
+ const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(id) + '"]');
+ try {
+ const H = window.innerHeight || 800;
+ if (!find() && c.heights) {
+ let sum = 0, n = 0;
+ for (const v of c.heights.values()) { sum += v; n++; }
+ const avg = n ? sum / n : 200;
+ let y = 0;
+ for (let j = 0; j < i; j++) y += (c.heights.get(list[j].id) || avg) + ROW_GAP;
+ window.scrollTo(0, Math.max(0, (c.listTop || 0) + y - MARGIN));
+ render();
+ }
+ const el = find();
+ if (!el) { glideToTop(); return; }
+ const target = Math.max(0, window.scrollY + el.getBoundingClientRect().top - MARGIN);
+ const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (reduce) { window.scrollTo({ top: target }); return; }
+ // a long way off: close most of the distance in one step, glide the rest
+ if (Math.abs(window.scrollY - target) > H * 2) window.scrollTo({ top: target + Math.sign(window.scrollY - target) * H * 2 });
+ requestAnimationFrame(() => { try { window.scrollTo({ top: target, behavior: 'smooth' }); } catch { window.scrollTo(0, target); } });
+ } catch { glideToTop(); }
}
// Up to the new posts as a glide, not a cut: an instant scrollTo read as a
// page refresh. A long way down, most of the distance is closed in one
@@ -4775,6 +5076,13 @@ export function messagesFeature(ctx) {
// as disruptive as a live arrival if you were reading halfway down, so it
// goes behind the pill too. A first load, or paging older posts onto the
// bottom, does not.
+ // The oldest event the relays handed back, admitted or not: a page of
+ // replies, muted or spam posts is not the end of the feed — the next ask
+ // goes on from there.
+ function sawBack(c, evs) {
+ for (const e of evs || []) if (e && e.created_at && !(c.seenBack <= e.created_at)) c.seenBack = e.created_at;
+ c.sawAny = (c.sawAny || 0) + ((evs || []).length);
+ }
async function feedPass(extra = {}, merge = {}, c = feedNow()) {
const def = feedDef(c.id);
if (!def) return false;
@@ -4787,7 +5095,8 @@ export function messagesFeature(ctx) {
// the firehose asks its relays for everything
if (!topics.length && !def.all) return false;
const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
- const evs = await queryOn(relays, { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ const evs = await queryOn(relays, { kinds: FEED_KINDS, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ sawBack(c, evs);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
return got;
}
@@ -4798,7 +5107,8 @@ export function messagesFeature(ctx) {
const chunks = [];
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
return chunks.map(async (chunk) => {
- const evs = await queryOn(relays, { kinds: [1], authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ const evs = await queryOn(relays, { kinds: FEED_KINDS, authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ sawBack(c, evs);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
});
}));
@@ -4969,11 +5279,11 @@ export function messagesFeature(ctx) {
const on = (ev) => { mergeFeed([ev], { live: true }, 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: [1], ...tag, since }, on)); return; }
+ if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; }
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: [1], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
+ feedUnsubs.push(subscribeOn(relays, { kinds: FEED_KINDS, authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
@@ -5009,8 +5319,14 @@ export function messagesFeature(ctx) {
if (c.status !== 'ready' || c.end) return;
const oldest = c.notes.at(-1);
if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
- if (await feedPass({ until: oldest.created_at - 1 }, {}, c)) c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length);
- else c.end = true;
+ 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;
+ }
} finally {
c.loadingMore = false;
if (c === feed) render();
@@ -5092,11 +5408,18 @@ export function messagesFeature(ctx) {
// window unmounting the row and mounting it again, a quoted note loading.
// Pinning the box alone left every one of those a muted restart.
const unmutedClips = new Set();
+ // A feed box takes the clip's own shape when it's known, as wide as the
+ // column allows but never taller than three quarters of the screen.
+ const videoBoxStyle = (d) => d && d.width && d.height
+ ? `width:min(100%, calc(75vh * ${d.width} / ${d.height}));aspect-ratio:${d.width}/${d.height};max-height:none;object-fit:contain`
+ : 'width:100%;aspect-ratio:16/9;object-fit:contain';
function videoNode(url, { stable = false } = {}) {
const loud = unmutedClips.has(url);
const v = h('video', { src: url, class: 'note-video', controls: true,
preload: 'metadata', playsinline: true, muted: loud ? undefined : true, // play() in view fetches; a window of videos must not all stream
- style: stable ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined,
+ 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),
onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
const box = h('div', { class: 'note-video-box' }, v);
@@ -5229,30 +5552,75 @@ export function messagesFeature(ctx) {
// wanted, half a minute on, with any relay hints the reference carried.
const QUOTE_RETRY_MS = 30_000;
const quoted = new Map(); // id -> { status, ev, at }
+ // Where a referenced note may be: the reference's hints, its author's own
+ // relays, ours — and when none of those has it, a wider net of big public
+ // relays (a quoted note in the wild sat only on nostr.mom, which neither
+ // the reference nor its author's relay list named).
+ // (the archive-keeping ones hold what the big relays have pruned — a poll
+ // two days old was only on ditto and nostrplebs by 2026-09-30)
+ const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com', 'wss://nostr21.com',
+ 'wss://nostr.wine', 'wss://relay.snort.social', 'wss://offchain.pub', 'wss://nostr.oxtr.dev',
+ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+ // All at once, first answer wins: asked in turn, a note on a far relay
+ // took longer than the feed waits for a row.
+ function findNote(ref) {
+ const pick = (evs) => (evs || []).find((e) => e.id === ref.id) || null;
+ const ask = (relays) => (relays.length ? queryOn(relays, { ids: [ref.id] }, 4500).then(pick).catch(() => null) : Promise.resolve(null));
+ const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
+ const asks = [
+ ask(near),
+ ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
+ ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
+ ];
+ return new Promise((resolve) => {
+ let left = asks.length;
+ for (const a of asks) a.then((ev) => { if (ev) resolve(ev); else if (--left === 0) resolve(null); });
+ });
+ }
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); }
c.at = Date.now();
c.promise = (async () => {
- const relays = [...new Set([...(ref.relays || []), ...zapRelays()])];
- const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []);
- c.ev = (evs || [])[0] || c.ev || null;
+ c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
+ if (c.ev) cacheNotifNotes([c.ev]);
+ // the open thread quotes it: keep it with the thread for the next reload
+ const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId);
+ if (th && th.root) persistThread(th);
scheduleRepaint();
})();
return c;
}
+ // A feed row that froze before its quote arrived showed a link — and
+ // nothing asked for the note again. Keep asking, and once the note and its
+ // own faces/pictures are in, the row takes the card.
+ function quoteWarm(ref) {
+ const c = quotedNote(ref);
+ if (c.ev && !c.warm && !c.warming) {
+ c.warming = noteReady(c.ev, Date.now() + READY_MS, 1).catch(() => {})
+ .then(() => { c.warm = true; c.warming = null; scheduleRepaint(); });
+ }
+ return c;
+ }
+
// The card. Deliberately not a noteRow: a quote is context, not another
// post to act on — no reply, no boost, no zap of its own. Tapping it opens
// the note properly, which is where those live.
function quoteCard(ref, depth) {
if (feedPaint && !feedPaint.quotes.has(ref.id)) feedPaint.quotes.set(ref.id, { ...quoted.get(ref.id) });
- const c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
- if (feedPaint && !c?.ev) return h('a', {
- href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); },
- }, t('noteRefLink'));
+ let c = feedPaint ? feedPaint.quotes.get(ref.id) : quotedNote(ref);
+ if (feedPaint && !c?.ev) {
+ const live = quoteWarm(ref);
+ // not (yet) found: the card says so where the quote will be — never a
+ // bare "view note" link that only leads to the same answer
+ if (!(live.ev && live.warm)) return h('div', { class: 'quote-card' },
+ h('span', { class: 'small faint' }, t(live.status === 'missing' ? 'noteRefNotFound' : 'noteRefLoading')));
+ c = { ...live };
+ feedPaint.quotes.set(ref.id, c);
+ }
if (c.status === 'loading') {
return h('div', { class: 'quote-card quote-loading' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small faint' }, t('noteRefLoading')));
@@ -5274,15 +5642,29 @@ export function messagesFeature(ctx) {
h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' },
// one level deep only: a quote of a quote of a quote is a rabbit
// hole, and the inner one stays a link you can follow
- ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))));
+ ...noteBody(ev.content, depth + 1, emojiTagMap(ev.tags))),
+ ev.kind === POLL ? pollBox(ev, { interactive: false }) : null);
}
// One URL, rendered as whatever it points at. `isImage` is markdown saying
// so outright — plenty of perfectly good picture URLs carry no extension
// (a CDN path, a /media/ route), and ![…] is the author telling us what it
// is, which beats guessing from the filename.
+ // A picture is fitted inside its box (object-fit: contain), so a tall one
+ // leaves blank bands either side. A tap there is a tap on the post — it
+ // opens the thread — not on the picture.
+ function onPicture(e) {
+ const el = e.currentTarget;
+ const r = el.getBoundingClientRect();
+ const nw = el.naturalWidth, nh = el.naturalHeight;
+ if (!nw || !nh || !r.width || !r.height) return true;
+ const k = Math.min(r.width / nw, r.height / nh);
+ const w = nw * k, hh = nh * k;
+ const x = e.clientX - (r.left + (r.width - w) / 2), y = e.clientY - (r.top + (r.height - hh) / 2);
+ return x >= 0 && x <= w && y >= 0 && y <= hh;
+ }
function urlNode(url, { label = null, isImage = false } = {}) {
- if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
+ if (VIDEO_RE.test(url)) {
return videoNode(url, { stable: !!feedPaint });
}
if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) {
@@ -5294,7 +5676,7 @@ export function messagesFeature(ctx) {
src: url, class: 'note-img clickable', loading: feedPaint ? 'eager' : 'lazy', alt: label || '',
width: size?.width, height: size?.height,
style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height : undefined,
- onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
+ onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
// a picture we were TOLD was a picture and which won't load leaves
// nothing behind — the alt text is already in the sentence above it
onError: (e) => { if (!size) e.target.style.display = 'none'; },
@@ -5417,7 +5799,7 @@ export function messagesFeature(ctx) {
// 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.
- const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download'];
+ const MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://nostr.download', 'https://blossom.yakihonne.com'];
const MEDIA_MAX = 20 * 1024 * 1024;
async function encryptFile(file) {
if (file.size > MEDIA_MAX) throw new Error(t('msgAttachTooBig'));
@@ -5453,7 +5835,8 @@ export function messagesFeature(ctx) {
const j = await r.json();
if (j && j.url) urls.push(j.url);
} catch {}
- if (urls.length && enc.cipher.length > 4 * 1024 * 1024) break; // one copy is enough for a big file
+ // one copy is enough for a big file, two for a small one — later hosts are fallbacks
+ if (urls.length >= (enc.cipher.length > 4 * 1024 * 1024 ? 1 : 2)) break;
}
if (!urls.length) throw new Error(t('msgUploadFailed'));
return urls;
@@ -5748,6 +6131,108 @@ export function messagesFeature(ctx) {
}
// React with whichever emoji was picked (NIP-25 takes any content).
+ // ---- polls (NIP-88) -------------------------------------------------------
+ // A kind 1068 poll is its question (content) plus ["option", id, label]
+ // tags; votes are kind 1018 events e-tagging it with ["response", id].
+ // Each person's LATEST vote counts (one option unless the poll says
+ // "multiplechoice"), and nothing cast after its endsAt.
+ const POLL = 1068, POLL_VOTE = 1018;
+ const pollOptions = (ev) => ev.tags.filter((x) => x[0] === 'option' && x[1] != null && x[2])
+ .map((x) => ({ id: String(x[1]), label: String(x[2]) }));
+ const pollMulti = (ev) => (ev.tags.find((x) => x[0] === 'polltype') || [])[1] === 'multiplechoice';
+ const pollEnds = (ev) => { const v = Number((ev.tags.find((x) => x[0] === 'endsAt') || [])[1]); return Number.isFinite(v) && v > 0 ? v : 0; };
+ const pollEnded = (ev) => !!pollEnds(ev) && Date.now() / 1000 > pollEnds(ev);
+ const pollRelays = async (ev) => [...new Set([
+ ...ev.tags.filter((x) => x[0] === 'relay' && /^wss?:\/\//i.test(x[1] || '')).map((x) => x[1]),
+ ...(await noteRelaysFor(ev).catch(() => zapRelays())),
+ ])].slice(0, 12);
+ const pollVotes = new Map(); // poll id -> { at, loaded, loading, by: Map(pk -> { t, ids }) }
+ function pollState(ev) {
+ let st = pollVotes.get(ev.id);
+ if (!st) { st = { at: 0, loaded: false, loading: false, by: new Map() }; pollVotes.set(ev.id, st); }
+ return st;
+ }
+ function pollCount(poll, v) {
+ const st = pollState(poll);
+ const ends = pollEnds(poll);
+ if (ends && v.created_at > ends) return;
+ const prev = st.by.get(v.pubkey);
+ if (prev && prev.t >= v.created_at) return;
+ const valid = new Set(pollOptions(poll).map((o) => o.id));
+ let ids = [...new Set(v.tags.filter((x) => x[0] === 'response').map((x) => String(x[1])))].filter((id) => valid.has(id));
+ if (!pollMulti(poll)) ids = ids.slice(0, 1);
+ st.by.set(v.pubkey, { t: v.created_at, ids });
+ }
+ // Votes are asked for when a poll is painted, and again a minute on.
+ function pollTally(ev) {
+ const st = pollState(ev);
+ if (!st.loading && Date.now() - st.at > 60_000) {
+ st.loading = true; st.at = Date.now();
+ (async () => {
+ const evs = await queryOn(await pollRelays(ev), { kinds: [POLL_VOTE], '#e': [ev.id], limit: 1000 }, 5000).catch(() => []);
+ for (const v of evs || []) if (v.kind === POLL_VOTE) pollCount(ev, v);
+ })().finally(() => { st.loading = false; st.loaded = true; scheduleRepaint(); });
+ }
+ return st;
+ }
+ async function votePoll(poll, optId) {
+ if (pollEnded(poll)) return;
+ const id = await requireIdentity();
+ const st = pollState(poll);
+ const prev = st.by.get(id.pubkey);
+ const mine = prev?.ids || [];
+ const ids = pollMulti(poll) ? (mine.includes(optId) ? mine.filter((x) => x !== optId) : [...mine, optId]) : [optId];
+ if (!pollMulti(poll) && mine[0] === optId) return;
+ const partial = {
+ kind: POLL_VOTE, content: '',
+ // strictly newer than our last vote, or relays and counters keep the old one
+ created_at: Math.max(Math.floor(Date.now() / 1000), (prev?.t || 0) + 1),
+ tags: [['e', poll.id], ['p', poll.pubkey], ...ids.map((x) => ['response', x]), CLIENT_TAG],
+ };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ pollCount(poll, evt); // counted on screen before the relays answer
+ render();
+ const ok = await publishOn(await pollRelays(poll), evt).catch(() => false);
+ if (!ok) toast(t('msgSendFailed'));
+ }
+ // The options as rows with their share of the vote. In a quote card
+ // (`interactive` off) they are a picture of the poll — the tap opens it.
+ function pollBox(ev, { interactive = true } = {}) {
+ const opts = pollOptions(ev);
+ if (!opts.length) return null;
+ const st = pollTally(ev);
+ const counts = new Map(opts.map((o) => [o.id, 0]));
+ let voters = 0;
+ for (const v of st.by.values()) {
+ if (!v.ids.length) continue;
+ voters++;
+ for (const x of v.ids) counts.set(x, (counts.get(x) || 0) + 1);
+ }
+ const mine = new Set(myPubkeys().flatMap((pk) => st.by.get(pk)?.ids || []));
+ const ended = pollEnded(ev);
+ const canVote = interactive && !ended;
+ const shown = st.loaded || voters > 0;
+ const foot = [
+ shown ? t(voters === 1 ? 'pollVoter' : 'pollVoters', { n: voters }) : null,
+ pollMulti(ev) ? t('pollMulti') : null,
+ ended ? t('pollEnded') : pollEnds(ev) ? t('pollEndsAt', { when: new Date(pollEnds(ev) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }) : null,
+ ].filter(Boolean).join(' \u00b7 ');
+ return h('div', { class: 'poll' },
+ ...opts.map((o) => {
+ const n = counts.get(o.id) || 0;
+ const pct = voters ? Math.round((n / voters) * 100) : 0;
+ return h(canVote ? 'button' : 'div', {
+ class: 'poll-opt' + (mine.has(o.id) ? ' mine' : '') + (canVote ? '' : ' static'),
+ type: canVote ? 'button' : undefined,
+ onClick: canVote ? (e) => { e.stopPropagation(); votePoll(ev, o.id).catch(() => {}); } : undefined,
+ },
+ h('span', { class: 'poll-bar', style: 'width:' + (shown ? pct : 0) + '%' }),
+ h('span', { class: 'poll-label' }, (mine.has(o.id) ? '\u2713 ' : '') + o.label),
+ shown ? h('span', { class: 'poll-pct' }, pct + '%') : null);
+ }),
+ foot ? h('div', { class: 'small faint poll-foot' }, foot) : null);
+ }
+
async function reactTo(ev, emoji) {
const id = await requireIdentity();
const relays = await noteRelaysFor(ev);
@@ -5794,7 +6279,7 @@ export function messagesFeature(ctx) {
ui.msgView = 'feed';
feedNow();
render();
- setTimeout(() => { const el = document.querySelector('.chat-page textarea'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
+ setTimeout(() => { const el = document.querySelector('.chat-page coinos-text'); if (el) { el.focus(); el.setSelectionRange(0, 0); } }, 80);
}
// ---- mute list (NIP-51 kind 10000) ---------------------------------------
@@ -6390,9 +6875,9 @@ export function messagesFeature(ctx) {
// An event of a kind this app has no renderer for (a chess game, kind
// 64, reached through a reaction to it) is shown as what its author's
// client said it is (NIP-31 alt), never as raw content dressed as a post.
- const foreign = ev.kind !== 1 && ev.kind !== 30023;
+ const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL && ev.kind !== COMMENT;
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
- const isReply = !foreign && ev.tags.some((x) => x[0] === 'e');
+ const isReply = ev.kind === COMMENT || (!foreign && ev.tags.some((x) => x[0] === 'e'));
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
@@ -6403,10 +6888,10 @@ export function messagesFeature(ctx) {
const openable = open && !pending;
if (!pending) watchZaps([ev.id]);
return h('div', {
- class: 'row',
+ class: 'row note-post',
'data-zap-post': ev.id,
'data-focus-note': focus ? '1' : undefined,
- style: 'gap:10px;align-items:flex-start;padding:10px 0'
+ style: 'padding:10px 0'
+ (openable ? ';cursor:pointer' : '')
+ (pending ? ';opacity:.55' : '')
// joined: the reply box continues this highlight below, so the
@@ -6419,8 +6904,8 @@ export function messagesFeature(ctx) {
// the avatar is its own tap-target (profile), even inside an openable
// row — its handler stops propagation, so the row still opens the thread
avatar(pk, 'chat-avatar note-avatar'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:3px' },
- h('div', { class: 'row between', style: 'align-items:center;gap:8px' },
+ h('div', { class: 'note-content' },
+ 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', {
// the name, like the avatar, is its own tap-target (profile)
@@ -6440,6 +6925,7 @@ export function messagesFeature(ctx) {
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' }, ...noteBody(ev.content, 0, emojiTagMap(ev.tags))),
+ ev.kind === POLL ? pollBox(ev) : null,
pending ? null : noteActions(pk, ev, { canZap }),
!pending && whoOpen(ev.id) ? whoPanel(ev) : null));
}
@@ -6454,7 +6940,21 @@ export function messagesFeature(ctx) {
const p = profiles.get(ev.pubkey);
if (p && (p.name || p.picture)) faces[ev.pubkey] = p;
}
- threadStore.save(c, c.focusId, faces, c.counts);
+ // quoted posts inside the thread ride along, so a reload paints them
+ // instead of a "Fetching note…" spinner
+ const quotes = [];
+ for (const ev of [c.root, ...c.replies]) {
+ for (const m of (ev.content || '').matchAll(/nostr:((?:note|nevent)1[a-z0-9]+)/gi)) {
+ const ref = parseNostrRef(m[1].toLowerCase());
+ const q = ref && ref.type === 'event' && quoted.get(ref.id);
+ if (q && q.ev) {
+ quotes.push(q.ev);
+ const p = profiles.get(q.ev.pubkey);
+ if (p && (p.name || p.picture)) faces[q.ev.pubkey] = p;
+ }
+ }
+ }
+ threadStore.save(c, c.focusId, faces, c.counts, quotes);
};
function persistThreadProfile(pk) {
const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
@@ -6490,7 +6990,16 @@ export function messagesFeature(ctx) {
settle();
requestAnimationFrame(settle);
}
+ // ---- NIP-22 comments (kind 1111) ----
+ // The reply format for everything that isn't a kind 1 note — a poll, an
+ // article, a picture. Uppercase tags name the ROOT (E/A + K + P),
+ // lowercase the PARENT it answers (e/a + k + p). A kind 1 note is never
+ // answered with one (NIP-22), and never answers anything but a kind 1.
+ const COMMENT = 1111;
+ const tagVal = (ev, name) => (ev.tags.find((x) => x[0] === name && x[1]) || [])[1] || null;
+ const addrOf = (ev) => ev.kind >= 30000 && ev.kind < 40000 ? ev.kind + ':' + ev.pubkey + ':' + (tagVal(ev, 'd') || '') : null;
function rootIdOf(ev) {
+ if (ev.kind === COMMENT) return tagVal(ev, 'E') || tagVal(ev, 'e') || ev.id;
const es = ev.tags.filter((x) => x[0] === 'e');
const marked = es.find((x) => x[3] === 'root');
return (marked || es[0] || [])[1] || ev.id;
@@ -6526,6 +7035,7 @@ 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,
root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} };
@@ -6547,8 +7057,12 @@ export function messagesFeature(ctx) {
};
let relays = await relaysFor(c.root || seed);
const [roots, replies] = await Promise.all([
- c.root ? Promise.resolve([]) : queryOn(relays, { kinds: [1], ids: [rootId] }, 4000),
- queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500),
+ c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll
+ // 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 || [])]),
]);
if (!c.root) c.root = (roots || [])[0] || null;
let all = [...c.replies, ...(replies || [])];
@@ -6561,8 +7075,8 @@ export function messagesFeature(ctx) {
if (up === top.id) break;
relays = [...new Set([...relays, ...(await relaysFor(top))])].slice(0, 14);
const [ups, more] = await Promise.all([
- queryOn(relays, { kinds: [1], ids: [up] }, 4000),
- queryOn(relays, { kinds: [1], '#e': [up], limit: 80 }, 4500),
+ queryOn(relays, { ids: [up] }, 4000),
+ queryOn(relays, { kinds: [1, COMMENT], '#e': [up], limit: 80 }, 4500),
]);
const upNote = (ups || [])[0];
if (!upNote) break;
@@ -6574,6 +7088,9 @@ export function messagesFeature(ctx) {
threadCache.set(topId, c);
if (ui.noteThread && ui.noteThread.rootId === rootId) ui.noteThread.rootId = topId;
}
+ // an article's comments may name it only by address
+ const addr = c.root && addrOf(c.root);
+ if (addr) all = [...all, ...(await queryOn(relays, { kinds: [COMMENT], '#A': [addr], limit: 120 }, 4500).catch(() => []) || [])];
for (const e of all) noteForSpam(e);
const seen = new Set([c.rootId]);
c.replies = all
@@ -6600,6 +7117,7 @@ export function messagesFeature(ctx) {
return c;
}
function openNoteThread(ev) {
+ rememberPlace('thread');
ui.noteThread = { rootId: rootIdOf(ev), focusId: ev.id, seed: ev, scrollPending: true };
ui.profOverThread = false; // a freshly opened thread goes on top
render();
@@ -6612,9 +7130,7 @@ export function messagesFeature(ctx) {
const cached = saved && (saved.root.id === ref.id ? saved.root : saved.replies.find((e) => e.id === ref.id));
if (cached) { openNoteThread(cached); return; }
toast(t('noteRefLoading'));
- const relays = [...new Set([...(ref.relays || []), ...NOTE_RELAYS])];
- const evs = await queryOn(relays, { ids: [ref.id] }, 4000).catch(() => []);
- const ev = (evs || [])[0];
+ const ev = quoted.get(ref.id)?.ev || await findNote(ref).catch(() => null);
if (ev) openNoteThread(ev);
else toast(t('noteRefNotFound'));
}
@@ -6676,7 +7192,28 @@ export function messagesFeature(ctx) {
])].filter((pk) => pk !== id.pubkey).slice(0, 8);
const imeta = (ui.postMedia || []).filter((m) => m && m.url && text.includes(m.url))
.map((m) => ['imeta', 'url ' + m.url, ...(m.m ? ['m ' + m.m] : [])]);
- const partial = {
+ const root = c.root || (target.id === rootId ? target : null);
+ // the root's scope: from the root itself, or — not loaded yet — copied
+ // off the comment being answered, which carries it
+ const scope = root
+ ? [...(addrOf(root) ? [['A', addrOf(root), '']] : []), ['E', root.id, '', root.pubkey], ['K', String(root.kind)], ['P', root.pubkey]]
+ : target.kind === COMMENT ? target.tags.filter((x) => ['A', 'E', 'I', 'K', 'P'].includes(x[0])) : [];
+ const partial = target.kind === 1 || !scope.length ? null : {
+ // NIP-22: a comment on a poll, an article, or on another comment
+ kind: COMMENT,
+ content: text,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ...scope,
+ ...(root && target.id === root.id
+ ? [...(addrOf(root) ? [['a', addrOf(root), '']] : []), ['e', root.id, '', root.pubkey], ['k', String(root.kind)], ['p', root.pubkey]]
+ : [['e', target.id, '', target.pubkey], ['k', String(target.kind)], ['p', target.pubkey]]),
+ ...pTags.filter((pk) => pk !== target.pubkey && (!root || pk !== root.pubkey)).map((pk) => ['p', pk]),
+ ...imeta,
+ CLIENT_TAG,
+ ],
+ };
+ const note = partial || {
kind: 1,
content: text,
created_at: Math.floor(Date.now() / 1000),
@@ -6688,7 +7225,7 @@ export function messagesFeature(ctx) {
CLIENT_TAG,
],
};
- const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ 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);
if (!ok) throw new Error(t('msgSendFailed'));
@@ -6720,14 +7257,21 @@ export function messagesFeature(ctx) {
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'),
},
s.preview ? draftPreview(s.draft) : null,
+ mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
// sends, Shift+Enter (or Ctrl+J) breaks the line — a reply used to be
// a one-line field with no way to write a paragraph.
- h('textarea', {
+ h('coinos-text', {
class: 'thread-reply-input', placeholder: t('threadReplyHint'),
- rows: String(Math.min(6, Math.max(2, (s.draft || '').split('\n').length))),
- style: 'font-family:var(--sans);resize:none;max-height:160px;overflow-y:auto;line-height:1.4;width:100%;box-sizing:border-box',
+ style: 'font-family:var(--sans);min-height:64px;max-height:160px',
value: s.draft || '',
+ // a keyboard GIF or a pasted picture: attached like the paperclip's
+ onMedia: (f) => attachTo(f, (url) => {
+ s.draft = ((s.draft || '').replace(/\s+$/, '') + ' ' + url).trim();
+ const inp = document.querySelector('.thread-reply-input');
+ if (inp) inp.value = s.draft;
+ s.preview = true;
+ }),
// a render per keystroke only while the preview is open; the morph
// leaves a focused field alone, so this can't fight the typing
onInput: (e) => { s.draft = e.target.value; growComposer(e.target); if (s.preview) render(); },
@@ -6748,7 +7292,7 @@ export function messagesFeature(ctx) {
// A reply can carry a picture too — same upload, same imeta tag, same
// paperclip. The URL lands in the draft, which is what every client
// reads as the media.
- ctx.uploadImage ? h('button', {
+ (ctx.uploadMedia || ctx.uploadImage) ? h('button', {
class: 'attach-btn', title: t('feedAttach'), 'aria-label': t('feedAttach'), disabled: !!ui.postUploading,
onClick: () => document.getElementById('reply-file')?.click(),
}, ui.postUploading ? h('span', { class: 'spinner sm' }) : h('span', { style: 'display:flex', html: CLIP })) : null,
@@ -6813,6 +7357,9 @@ export function messagesFeature(ctx) {
// 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;
@@ -7339,26 +7886,66 @@ export function messagesFeature(ctx) {
// client reads media — with a NIP-92 imeta tag published beside it for the
// ones that would rather have the metadata than sniff the URL.
const composeText = () => (ui.profCompose == null ? draftFor(POST_DRAFT) : ui.profCompose || '');
+ let postAttachGeneration = 0;
// Upload, then hand the URL to whichever draft asked for it.
async function attachTo(file, place) {
- if (!file || !ctx.uploadImage) return;
+ const upload = ctx.uploadMedia || ctx.uploadImage;
+ if (!file || !upload) return;
ui.postUploading = true; render();
try {
- const url = await ctx.uploadImage(file);
- (ui.postMedia ||= []).push({ url, m: file.type || '' });
- place(url);
+ const url = await upload(file);
+ // 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 || '' });
} catch (e) { toast(e.message || String(e)); }
ui.postUploading = false; render();
}
- const attachMedia = (file) => attachTo(file, (url) => {
- const cur = composeText().replace(/\s+$/, '');
- const next = (cur ? cur + '\n' : '') + url;
- ui.profCompose = next;
- setDraft(POST_DRAFT, next);
- // you just attached a picture; showing it IS the answer to the question
- // attaching one raises
- ui.postPreview = true;
- });
+ const attachMedia = (file) => {
+ const generation = postAttachGeneration;
+ return attachTo(file, (url) => {
+ if (generation !== postAttachGeneration) return false;
+ const cur = composeText().replace(/\s+$/, '');
+ const next = (cur ? cur + '\n' : '') + url;
+ ui.profCompose = next;
+ setDraft(POST_DRAFT, next);
+ // you just attached a picture; showing it IS the answer to the question
+ // attaching one raises
+ ui.postPreview = true;
+ return true;
+ });
+ };
+ function cancelPost() {
+ postAttachGeneration++;
+ ui.postCancelConfirm = false;
+ ui.profCompose = null;
+ ui.postMedia = [];
+ ui.postPreview = false;
+ discardDraft(POST_DRAFT);
+ render();
+ }
+ function requestCancelPost() {
+ if (composeText().length > 20 || ui.postUploading || (ui.postMedia || []).length) {
+ ui.postCancelConfirm = true;
+ render();
+ return;
+ }
+ cancelPost();
+ }
+ function postCancelWarning() {
+ if (!ui.postCancelConfirm) return null;
+ const keep = () => { ui.postCancelConfirm = false; render(); };
+ return h('div', {
+ class: 'confirm-pop-backdrop',
+ onClick: (event) => { if (event.target === event.currentTarget) keep(); },
+ },
+ h('div', { class: 'card col confirm-pop', style: 'gap:10px' },
+ h('h3', { style: 'margin:0' }, t('postDiscardTitle')),
+ h('div', { class: 'small muted' }, t('postDiscardBody')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost grow', onClick: keep }, t('postKeepEditing')),
+ h('button', { class: 'btn-danger grow', onClick: cancelPost }, t('postDiscard')))));
+ }
const CLIP = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
const EYE = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
@@ -7377,6 +7964,16 @@ export function messagesFeature(ctx) {
? h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(body))
: h('div', { class: 'small faint' }, t('composePreviewEmpty')));
}
+ // Shrinking, then uploading, as a labelled bar with its percentage — the
+ // paperclip's spinner said only "busy", however long a video took.
+ function mediaBar() {
+ const m = ui.mediaProgress;
+ if (!m) return null;
+ const label = t(m.stage === 'shrink' ? 'mediaShrinking' : 'mediaUploading');
+ return h('div', { class: 'media-progress', role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(m.pct), 'aria-label': label },
+ h('div', { class: 'row between small' }, h('span', { class: 'muted' }, label + '…'), h('span', { class: 'media-progress-pct' }, m.pct + '%')),
+ h('div', { class: 'media-progress-track' }, h('div', { class: 'media-progress-fill', style: 'width:' + m.pct + '%' })));
+ }
const previewBtn = (on, toggle) => h('button', {
class: 'attach-btn' + (on ? ' on' : ''),
title: on ? t('composePreviewHide') : t('composePreview'),
@@ -7387,10 +7984,12 @@ export function messagesFeature(ctx) {
if (ui.profCompose == null && !draftFor(POST_DRAFT)) return null;
const text = composeText();
return h('div', { class: 'col', style: 'gap:8px' },
- h('textarea', {
- rows: '3', placeholder: t('profComposePh'),
- style: 'font-family:var(--sans);min-height:64px',
+ h('coinos-text', {
+ class: 'post-input', placeholder: t('profComposePh'),
+ style: 'font-family:var(--sans);min-height:84px;max-height:320px',
value: text,
+ // the field is focused, so the morph leaves it: put the URL in by hand
+ onMedia: (f) => attachMedia(f).then(() => { const el = document.querySelector('.post-input'); if (el) el.value = composeText(); }),
onInput: (ev) => {
ui.profCompose = ev.target.value;
setDraft(POST_DRAFT, ev.target.value);
@@ -7401,6 +8000,7 @@ export function messagesFeature(ctx) {
},
}),
ui.postPreview ? draftPreview(text) : null,
+ mediaBar(),
h('div', { class: 'row gap6' },
h('button', { class: 'btn-primary grow', disabled: !!ui.postUploading, onClick: async () => {
const body = composeText().trim();
@@ -7420,7 +8020,7 @@ export function messagesFeature(ctx) {
render();
}
} }, t('profPostBtn')),
- ctx.uploadImage ? h('button', {
+ (ctx.uploadMedia || ctx.uploadImage) ? h('button', {
class: 'attach-btn', 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,
@@ -7429,7 +8029,8 @@ export function messagesFeature(ctx) {
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('button', { class: 'btn-ghost', onClick: () => { ui.profCompose = null; render(); } }, t('cancel'))));
+ h('button', { class: 'btn-ghost', onClick: requestCancelPost }, t('cancel'))),
+ postCancelWarning());
}
// ---- the feed view --------------------------------------------------------
@@ -7454,7 +8055,9 @@ export function messagesFeature(ctx) {
...items.map((f) => h('button', {
class: 'feed-chip' + (f.id === curFeedId ? ' on' : ''), type: 'button', 'data-key': 'chip:' + f.id,
onClick: () => switchFeed(f.id),
- }, f.name)),
+ // someone's timeline carries the person, not a name — an empty
+ // label left a blank black pill
+ }, f.name || (f.of ? t('feedOfTitle', { name: displayName(f.of) }) : feedSummary(f)))),
h('button', { class: 'feed-chip add', type: 'button', title: t('feedNew'), 'aria-label': t('feedNew'), onClick: () => openFeedEditor(null) }, '+'));
}
// The sheets a post (or a person) can open: the ⋯ menu, the reaction
@@ -7475,7 +8078,9 @@ export function messagesFeature(ctx) {
item('\u{1F4E1}', t('feedRelays') + (feedRelays(def) ? ' \u00b7 ' + feedRelays(def).length : ''), () => { ui.feedRelayEdit = { id: def.id, input: '' }; }),
def.builtin || visitor ? null
: adhocFeeds.has(def.id)
- ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => openFeedEditor(def))
+ ? item('\u{1F4BE}', t('feedSaveAdhoc'), () => (def.of ? saveTimelineFeed(def) : openFeedEditor(def)))
+ // a saved timeline is the person, nothing to edit — only to drop
+ : def.of ? item('\u{1F5D1}', t('feedDelete'), () => deleteFeed(def.id))
: item('\u270e', t('feedEdit'), () => openFeedEditor(def)),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
@@ -7524,6 +8129,64 @@ export function messagesFeature(ctx) {
} catch {}
}, 0);
}
+ function appBottomNav() {
+ const visitor = isVisitor();
+ const active = ui.noteThread || ui.profilePk ? 'feed' : ui.chatOpen
+ ? ui.msgView === 'feed' ? 'feed' : ui.msgView === 'notifs' ? 'notifs' : 'messages' : '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',
+ 'zapSetup', 'hatShop', 'feedEdit', 'feedMenu', 'feedRelayEdit', 'noteSheet', 'reactPick', 'reportSheet',
+ 'msgReplyTo', 'msgSheet', 'arkCoinsPage', 'arkExitPage', 'nameEditOpen', 'nostrReconnect', 'nostrLoginOpen',
+ 'pos', 'lockedGift', 'viewGift', 'claimChoose', 'signinAsk']) ui[key] = null;
+ if (!visitor) { ui.screen = 'wallet'; ui.pubProf = null; }
+ };
+ // The chat list used to carry the notifications row and its count; the
+ // nav's bell carries the dot now.
+ const notifsWaiting = () => {
+ if (!myPubkeys().length) return 0;
+ refreshNotifs(); // throttled inside; keeps the count honest
+ return notifUnread();
+ };
+ const icon = (paths) => '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + paths + '</svg>';
+ const button = (id, label, paths, onClick) => h('button', {
+ type: 'button', class: 'app-nav-button' + (!visitor && (id === 'messages' ? hook('unreadMessages') : id === 'notifs' && notifsWaiting()) ? ' unread' : ''),
+ 'aria-label': label, 'aria-current': active === id ? 'page' : undefined, onClick,
+ }, h('span', { html: icon(paths) }), h('span', {}, label));
+ return h('nav', {
+ class: 'app-bottom-nav', 'data-key': 'app-bottom-nav',
+ },
+ button('feed', t('feedTitle'), '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2m0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8M15 18h-5M10 6h8v4h-8z"/>', () => {
+ // A destination tab is a destination: tapping Feed while already
+ // there always returns to its top. Only the explicit "x new posts"
+ // pill jumps to the first unread arrival.
+ // (someone's timeline or a #tag opened from a post isn't the Feed
+ // tab — the tab is your own feeds, the one you last had on)
+ const own = adhocFeeds.has(curFeedId) ? homeFeedId() : curFeedId;
+ if (active === 'feed' && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && !ui.feedEdit && own === curFeedId) { glideToTop(); return; }
+ leavePage();
+ if (visitor) { ui.pubProf = true; openFirehose(); } else showFeed(own);
+ window.scrollTo({ top: 0 });
+ }),
+ button('messages', t('msgDmsTitle'), '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/>', () => {
+ if (visitor) { toFrontDoor(); return; }
+ leavePage(); ui.chatOpen = true; ui.msgView = 'home'; render();
+ window.scrollTo({ top: 0 });
+ }),
+ button('notifs', t('alertsTitle'), '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/>', () => {
+ if (visitor) { toFrontDoor(); return; }
+ leavePage(); openNotifs();
+ window.scrollTo({ top: 0 });
+ }),
+ button('wallet', t('settingsWallet'), '<path d="M20 8V5H5a2 2 0 0 0 0 4h16v11H5a2 2 0 0 1-2-2V7"/><path d="M21 12h-5v5h5"/><path d="M17 14.5h.01"/>', () => {
+ if (isVisitor()) { toFrontDoor(); return; }
+ leavePage();
+ ui.txDetail = null; ui.arkMoveDetail = null; ui.arkReconDetail = null; ui.arkExitDetail = null; ui.giftDetail = null;
+ ui.chatOpen = false; ui.msgView = null; ui.tab = 'receive';
+ render(); window.scrollTo({ top: 0 });
+ }));
+ }
function feedView() {
syncFollowSets().catch(() => {}); // throttled inside
syncReports().catch(() => {}); // likewise
@@ -7563,31 +8226,42 @@ export function messagesFeature(ctx) {
waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord'))
: null;
// the chat shell draws the brand header; this is just the page under it
- return h('div', { class: 'card col chat-page', style: 'gap:10px' },
+ return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' },
h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill),
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => {
stopFeedWatch();
+ // a feed opened from somewhere (a profile's timeline, a #tag, a
+ // pack link) goes back there — or, reached directly, to yours
+ if (!visitor && adhocFeeds.has(def.id)) { ctx.goBack(() => { ui.chatOpen = true; ui.msgView = 'feed'; selectFeed(homeFeedId()); }); return; }
if (visitor) { ui.chatOpen = false; ui.msgView = null; ui.pubProf = null; } else ui.msgView = 'home';
render();
}),
def.of ? avatar(def.of, 'chat-avatar mini', true) : null,
h('h3', { style: 'margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
- def.builtin && !def.all ? t('feedTitle') : def.of ? t('feedOfTitle', { name: displayName(def.of) }) : def.name),
+ def.builtin && !def.all ? t('feedTitle') : def.of && !def.name ? t('feedOfTitle', { name: displayName(def.of) }) : def.name),
// one ⋯ for the feed itself (share, relays, save or edit) and one
// pencil for a new post: the title keeps the room a phone has
h('button', {
class: 'btn-sm' + (feedRelays(def) ? ' on' : ''), title: t('feedMenu'), 'aria-label': t('feedMenu'), style: 'margin-left:auto;flex-shrink:0',
onClick: () => { ui.feedMenu = true; render(); },
}, '\u22ef'),
- visitor ? null : h('button', {
- class: 'btn-sm', style: 'flex-shrink:0', title: t('profNewPost'), 'aria-label': t('profNewPost'),
- onClick: () => { ui.profCompose = ui.profCompose == null ? (draftFor(POST_DRAFT) || '') : null; render(); },
- html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' })),
+ ),
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
+ // 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', {
+ class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'),
+ onClick: () => {
+ ui.profCompose = draftFor(POST_DRAFT) || '';
+ render();
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
+ setTimeout(() => document.querySelector('.post-input')?.focus(), 60);
+ },
+ }, h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' }), h('span', {}, t('feedPostFab'))),
visitor ? null : postComposer(),
- def.of && !def.ofAt
+ def.of && !ofLoaded(def)
? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedOfLoading')))
: def.of && !hasQuery
@@ -7673,6 +8347,22 @@ export function messagesFeature(ctx) {
deleteFollowSet(prev).catch(() => {});
}
}
+ // "Save feed" on someone's timeline: into the chips at once. The editor
+ // would have copied their whole follow list in, one row per person.
+ function saveTimelineFeed(adhoc) {
+ const s = st();
+ const have = s.feeds.find((f) => f.of === adhoc.of && JSON.stringify(f.relays || []) === JSON.stringify(adhoc.relays || []));
+ const id = have ? have.id : 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ if (!have) {
+ s.feeds.push({ id, name: '', follows: false, authors: [], packs: [], topics: [], of: adhoc.of,
+ ...(adhoc.relays && adhoc.relays.length ? { relays: adhoc.relays } : {}), at: Date.now() });
+ save(s);
+ }
+ adhocFeeds.delete(adhoc.id);
+ feedStates.delete(adhoc.id);
+ switchFeed(id);
+ toast(t('feedSaved'));
+ }
function deleteFeed(id) {
const s = st();
const def = s.feeds.find((f) => f.id === id);
@@ -8051,9 +8741,10 @@ export function messagesFeature(ctx) {
type: 'file', id: 'msg-file', accept: 'image/*,video/*', style: 'display:none',
onChange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) onAttach(f); },
}) : null,
- h('textarea', {
- class: 'grow', id: 'msg-draft', placeholder, rows: String(Math.min(5, draftFor(draftKey).split('\n').length)),
- value: draftFor(draftKey), maxlength: '2000',
+ h('coinos-text', {
+ class: 'grow', id: 'msg-draft', placeholder,
+ value: draftFor(draftKey),
+ onMedia: onAttach ? (f) => onAttach(f) : null,
onInput: (e) => { setDraft(draftKey, e.target.value); growComposer(e.target); updateEmojiAc(e.target, draftKey); if (onType && e.target.value) onType(); },
onClick: (e) => updateEmojiAc(e.target, draftKey), // the caret moved
onBlur: () => { if (ui.emojiAc) { ui.emojiAc = null; render(); } },
@@ -8157,45 +8848,6 @@ export function messagesFeature(ctx) {
if (st().noPushService && !st().push)
kids.push(h('div', { class: 'notice info small' }, pushAdvice()));
- // ---- the feed, above the conversations: it's the thing you read, they're
- // the things you answer
- kids.push(h('div', { class: 'list' },
- h('div', {
- class: 'item chat-thread-row',
- onClick: () => { ui.msgView = 'feed'; switchFeed(FOLLOWING); },
- },
- h('div', { class: 'chat-avatar fallback' }, '\u2605'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('span', { class: 'chat-name' }, t('feedTitle')),
- h('div', { class: 'muted small' },
- followsNow().set.size === 1 ? t('feedFollowing1')
- : followsNow().set.size ? t('feedFollowingN', { n: followsNow().set.size })
- : t('feedNoFollowsShort')))),
- // ...the feeds you made, each its own row under it
- ...st().feeds.map((f) => h('div', {
- class: 'item chat-thread-row',
- onClick: () => { ui.msgView = 'feed'; switchFeed(f.id); },
- },
- h('div', { class: 'chat-avatar fallback' }, feedTopics(f).length && !f.follows && !(f.authors || []).length && !(f.packs || []).length ? '#' : '\u2605'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('span', { class: 'chat-name' }, f.name),
- h('div', { class: 'muted small chat-preview' }, feedSummary(f))))),
- // ...and what happened to what you posted
- (() => {
- const n = myPubkeys().length ? notifUnread() : 0;
- if (myPubkeys().length) refreshNotifs(); // throttled inside; keeps the count honest
- return h('div', {
- class: 'item chat-thread-row' + (n ? ' unread' : ''),
- onClick: openNotifs,
- },
- h('div', { class: 'chat-avatar fallback' }, '\ud83d\udd14'),
- h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
- h('span', { class: 'chat-name' }, t('alertsTitle')),
- h('div', { class: 'muted small' }, n ? t('alertsNew', { n }) : t('alertsSub'))),
- // like a group row: the dot sits mid-height, padded off the edge
- n ? h('i', { class: 'thread-dot' }) : null);
- })()));
-
// ---- DMs
kids.push(h('div', { class: 'row between', style: 'align-items:baseline' },
h('h3', { style: 'margin:0' }, t('msgDmsTitle')),
@@ -8231,11 +8883,10 @@ export function messagesFeature(ctx) {
? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => openThread(r.pk), (pk, node) => hook('wrapAvatar', pk, node)))
: h('div', { class: 'small muted' }, t('msgNoMatches'))));
}
- // A long DM history must not bury the communities below it: past a
- // handful, the rest waits behind "show all". (No cap for barely-over —
- // a "show 2 more" button costs more than the rows it hides.)
- const DM_PREVIEW = 5;
- const shownDms = (ui.msgAllDms || dmRows.length <= DM_PREVIEW + 2) ? dmRows : dmRows.slice(0, DM_PREVIEW);
+ // A long DM history must not bury the communities below it: past four,
+ // the rest waits behind "show all". Communities get the same cap.
+ const LIST_PREVIEW = 4;
+ const shownDms = ui.msgAllDms ? dmRows : dmRows.slice(0, LIST_PREVIEW);
kids.push(
dmRows.length
? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) =>
@@ -8255,7 +8906,7 @@ export function messagesFeature(ctx) {
if (shownDms.length < dmRows.length)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } },
t('msgShowAllDms', { n: dmRows.length })));
- else if (ui.msgAllDms && dmRows.length > DM_PREVIEW + 2)
+ else if (ui.msgAllDms && dmRows.length > LIST_PREVIEW)
kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = false; render(); } },
t('msgShowFewerDms')));
@@ -8292,7 +8943,12 @@ export function messagesFeature(ctx) {
value: ui.msgNewName || '', onInput: (e) => { ui.msgNewName = e.target.value; },
}),
h('button', { class: 'btn-sm', onClick: () => createCommunity(ui.msgNewName || '') }, t('msgCreate'))));
- kids.push(h('div', { class: 'list' }, communities().map((jm) => {
+ const allRooms = communities();
+ const shownRooms = ui.msgAllRooms ? allRooms : allRooms.slice(0, LIST_PREVIEW);
+ // the rooms on the list get their faces while it sits there, so even a
+ // tap with no hover before it finds them ready
+ setTimeout(() => { for (const jm of shownRooms) if (rooms.has(jm.community_id)) warmRoom(jm); }, 300);
+ kids.push(h('div', { class: 'list' }, shownRooms.map((jm) => {
const room = rooms.get(jm.community_id);
const name = room?.folded?.metadata?.name || jm.name;
// Last session's settled count anchors the number: while the guestbook
@@ -8306,7 +8962,9 @@ export function messagesFeature(ctx) {
const unread = room ? roomUnread(room) : false;
return h('div', {
class: 'item chat-thread-row' + (unread ? ' unread' : ''),
- onClick: () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; render(); },
+ onMouseenter: () => warmRoom(jm),
+ onPointerdown: () => warmRoom(jm),
+ onClick: () => openRoomWarm(jm, () => { ui.msgView = 'room'; ui.msgCommunity = jm.community_id; ui.msgChannel = null; ui.msgStick = true; }),
},
h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)),
h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
@@ -8316,6 +8974,12 @@ export function messagesFeature(ctx) {
// the row's own centring puts the dot mid-height, off the edge
unread ? h('i', { class: 'thread-dot' }) : null);
})));
+ if (shownRooms.length < allRooms.length)
+ kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllRooms = true; render(); } },
+ t('msgShowAllCommunities', { n: allRooms.length })));
+ else if (ui.msgAllRooms && allRooms.length > LIST_PREVIEW)
+ kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllRooms = false; render(); } },
+ t('msgShowFewerDms')));
return h('div', { class: 'card col chat-page', style: 'gap:10px' }, ...kids);
}
@@ -8351,6 +9015,45 @@ export function messagesFeature(ctx) {
// ---- room ---------------------------------------------------------------
+ // ---- a room's faces, before it opens -------------------------------------
+ // Opening a community painted everyone as a punk for a beat: their
+ // profiles weren't in memory yet. As SvelteKit preloads on hover, a
+ // pointer over (or pressing) a room's row starts the faces of its latest
+ // messages — profile, then the picture decoded — and the tap waits for
+ // them, a moment at most, before the room replaces the list.
+ const ROOM_READY_MS = 1500;
+ const roomWarm = new Map(); // cid -> { at, promise }
+ let roomOpenSeq = 0;
+ function roomFaces(jm) {
+ const room = ensureRoom(jm);
+ const ch = roomChannels(room)[0];
+ if (!ch) return [];
+ // everyone the channel paints, newest speakers first
+ const msgs = [...(room.byChannel.get(ch.id)?.values() || [])]
+ .sort((a, b) => eventMs(b.rumor) - eventMs(a.rumor));
+ return [...new Set(msgs.map((m) => m.author))].slice(0, 150);
+ }
+ // Each face is warmed once per half minute; a later call (the history
+ // still streaming in when the list first warmed) adds whoever is new.
+ function warmRoom(jm) {
+ let w = roomWarm.get(jm.community_id);
+ if (!w || Date.now() - w.at > 30_000) { w = { at: Date.now(), faces: new Map() }; roomWarm.set(jm.community_id, w); }
+ const deadline = Date.now() + 8000;
+ try {
+ for (const pk of roomFaces(jm)) if (!w.faces.has(pk)) w.faces.set(pk, warmAvatar(pk, deadline).catch(() => {}));
+ } catch {}
+ return Promise.all(w.faces.values());
+ }
+ async function openRoomWarm(jm, go) {
+ const seq = ++roomOpenSeq;
+ const from = ui.msgView;
+ await Promise.race([warmRoom(jm), new Promise((r) => setTimeout(r, ROOM_READY_MS))]);
+ // another tap, or the reader went elsewhere, while the faces loaded
+ if (seq !== roomOpenSeq || ui.msgView !== from || !ui.chatOpen) return;
+ go();
+ render();
+ }
+
function messageRows(room, chId) {
const my = myPubkeys();
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
@@ -8693,10 +9396,11 @@ export function messagesFeature(ctx) {
// kept as a short list of what-happened rows rather than the raw events —
// a zap receipt drags its whole request along, and the list rides in the
// feature state. Painted from the last visit while the relays are asked.
- const NOTIF_KINDS = [1, 6, 7, ...ZAP_KINDS];
+ const NOTIF_KINDS = [1, COMMENT, 6, 7, ...ZAP_KINDS];
const NOTIF_KEEP = 150;
let notif = null; // { status, items }
let notifAt = 0, notifUnsub = null;
+ const notifNoteAsking = new Set();
const notifSeen = () => st().notifSeen || 0;
// Notifications are about the IDENTITY, not the wallet: the stored list
// remembers whose it is, and another identity on the same wallet starts
@@ -8707,7 +9411,17 @@ export function messagesFeature(ctx) {
const s = st();
const stored = (!s.notifsPk || s.notifsPk === identityPk()) ? (s.notifs || []) : [];
notif = { status: stored.length ? 'ready' : 'loading', items: stored };
+ // Reply events and the notes reactions/zaps point at ride beside their
+ // notification rows. Seed the in-memory note caches synchronously, so
+ // opening Notifications after a reload never paints "Fetching note…".
+ const noteCache = s.notifNotesPk === identityPk() ? (s.notifNoteCache || {}) : {};
+ for (const note of Object.values(noteCache)) {
+ if (!note || !note.id) continue;
+ notifNotes.set(note.id, note);
+ quoted.set(note.id, { status: 'ready', ev: note, at: Date.now() });
+ }
warmNotifFaces(stored);
+ warmNotifNotes(stored);
refreshNotifs();
}
return notif;
@@ -8725,6 +9439,58 @@ export function messagesFeature(ctx) {
const deadline = Date.now() + READY_MS;
for (const pk of pks) warmAvatar(pk, deadline).catch(() => {});
}
+ // Keep only what rendering and opening a thread need. Signatures and relay
+ // metadata make this cache unnecessarily large; the relays remain the
+ // source of truth whenever the live notification refresh runs.
+ const savedNotifNote = (ev) => ev && ev.id ? ({
+ id: ev.id, pubkey: ev.pubkey, kind: ev.kind, created_at: ev.created_at,
+ content: ev.content || '', tags: ev.tags || [],
+ }) : null;
+ function persistNotifs() {
+ if (!notif) return;
+ const wanted = new Set();
+ for (const x of notif.items) {
+ const id = x.what === 'reply' || x.what === 'mention' ? x.id : x.target;
+ if (id) wanted.add(id);
+ }
+ const notes = {};
+ 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.notifNoteCache = notes; s2.notifNotesPk = identityPk();
+ save(s2);
+ }
+ function cacheNotifNotes(evs) {
+ if (!notif || !(evs || []).length) return false;
+ const byId = new Map((evs || []).filter((ev) => ev && ev.id).map((ev) => [ev.id, savedNotifNote(ev)]));
+ let changed = false;
+ const wanted = new Set();
+ for (const x of notif.items) wanted.add(x.what === 'reply' || x.what === 'mention' ? x.id : x.target);
+ for (const [id, note] of byId) {
+ if (!wanted.has(id) || notifNotes.has(id)) continue;
+ notifNotes.set(id, note);
+ quoted.set(id, { status: 'ready', ev: note, at: Date.now() });
+ changed = true;
+ }
+ if (changed) persistNotifs();
+ return changed;
+ }
+ // Old notification rows did not carry their note. Upgrade them in one
+ // bounded batch while the bell/chat home is visible, before the user opens
+ // the page. New rows take the same path as soon as they arrive.
+ function warmNotifNotes(items) {
+ const ids = [...new Set((items || [])
+ .filter((x) => x && x.target && x.what !== 'reply' && x.what !== 'mention' && !notifNotes.has(x.target))
+ .map((x) => x.target).filter((id) => !notifNoteAsking.has(id)))].slice(0, NOTIF_KEEP);
+ if (!ids.length) return;
+ for (const id of ids) notifNoteAsking.add(id);
+ (async () => {
+ const got = [];
+ for (let i = 0; i < ids.length; i += 60)
+ got.push(...await queryOn(zapRelays(), { ids: ids.slice(i, i + 60) }, 4500).catch(() => []));
+ if (cacheNotifNotes(got)) scheduleRepaint();
+ })().finally(() => { for (const id of ids) notifNoteAsking.delete(id); });
+ }
// What an event says happened, or null if it isn't about you after all.
function notifItem(ev) {
const my = myPubkeys();
@@ -8745,6 +9511,10 @@ export function messagesFeature(ctx) {
return { id: ev.id, what: 'react', actor: ev.pubkey, target: lastE, hint, emoji, ts: ev.created_at };
}
if (ev.kind === 6) return lastE ? { id: ev.id, what: 'boost', actor: ev.pubkey, target: lastE, hint, ts: ev.created_at } : null;
+ if (ev.kind === COMMENT) {
+ const parent = tagVal(ev, 'e') || tagVal(ev, 'E');
+ return { id: ev.id, what: parent ? 'reply' : 'mention', actor: ev.pubkey, target: parent, text: String(ev.content || '').slice(0, 300), ts: ev.created_at, pubkey: ev.pubkey };
+ }
if (ev.kind === 1) {
const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]);
const replyTo = (es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es.at(-1) || [])[1] || null;
@@ -8756,7 +9526,7 @@ export function messagesFeature(ctx) {
const c = notifNow();
const known = new Set(c.items.map((x) => x.id));
for (const ev of evs || []) noteForSpam(ev);
- const add = (evs || []).map((ev) => (ev.kind === 1 && hidden(ev) ? null : notifItem(ev)))
+ const add = (evs || []).map((ev) => ((ev.kind === 1 || ev.kind === COMMENT) && hidden(ev) ? null : notifItem(ev)))
.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id));
if (!add.length) return false;
warmNotifFaces(add);
@@ -8771,11 +9541,12 @@ export function messagesFeature(ctx) {
kept.push(x);
}
c.items = kept.slice(0, NOTIF_KEEP);
- // the rows already say who; a reply row also wants its reader-facing text,
- // which it carries — the events themselves are not kept
- const s2 = st(); s2.notifs = c.items; s2.notifsPk = identityPk(); save(s2);
- // a reply is a note we can open straight away; keep it in hand
- for (const ev of evs) if (ev.kind === 1) notifNotes.set(ev.id, ev);
+ // Keep the small notification rows and one deduplicated copy of each note
+ // they show. A reply is also a note we can open straight away.
+ for (const ev of evs) if (ev.kind === 1 || ev.kind === COMMENT) notifNotes.set(ev.id, ev);
+ cacheNotifNotes(evs);
+ warmNotifNotes(c.items);
+ persistNotifs();
return true;
}
const notifNotes = new Map(); // reply id -> event, for opening the thread
@@ -8997,6 +9768,7 @@ export function messagesFeature(ctx) {
if (!Object.keys(s.read || {}).length && !anyMsgs) return 1;
return unreadCount();
},
+ bottomNav: appBottomNav,
notifySettingsCards() { return [notifyCard()]; },
screenView() {
// A profile deep link mid-resolution holds the frame over EVERY screen
diff --git a/src/feed-cache.js b/src/feed-cache.js
index ea562f7..2c0b153 100644
--- a/src/feed-cache.js
+++ b/src/feed-cache.js
@@ -1,17 +1,20 @@
// Disposable public feed cache. Budget is conservative UTF-16 storage bytes.
+// The feeds read most recently keep a full page; the rest keep a few seed
+// posts, so switching to any feed paints something before the relays answer.
export const FEED_CACHE_POSTS = 30;
-const MAX_FEEDS = 4, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000;
+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 && typeof e.id === 'string' && typeof e.pubkey === 'string'
+ const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string'
&& typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags);
- function compact(notes) {
+ function compact(notes, max = FEED_CACHE_POSTS) {
const kept = []; let size = 0;
for (const e of (Array.isArray(notes) ? notes : []).filter(valid).sort((a, b) => b.created_at - a.created_at)) {
const cost = JSON.stringify(e).length;
if (size + cost > MAX_UNITS - 1000) continue;
kept.push(e); size += cost;
- if (kept.length === FEED_CACHE_POSTS) break;
+ if (kept.length === max) break;
}
return kept;
}
@@ -23,19 +26,24 @@ export function createFeedCache(storage, prefix, now = Date.now) {
try {
const text = storage.getItem(key);
const raw = JSON.parse(text);
- const entry = Array.isArray(raw) ? { at: now(), notes: compact(raw) } : raw;
+ const entry = Array.isArray(raw) ? { at: now(), notes: raw } : raw;
if (!entry || !Number.isFinite(entry.at) || now() - entry.at > MAX_AGE) entries.push({ key });
- else entries.push({ key, text, value: { at: entry.at, notes: compact(entry.notes) } });
+ else entries.push({ key, text, entry });
} catch { entries.push({ key }); }
}
- entries.sort((a, b) => (b.key === preferred) - (a.key === preferred) || (b.value?.at || 0) - (a.value?.at || 0));
+ // A feed that was read beats one only warmed in the background (seed).
+ entries.sort((a, b) => (b.key === preferred) - (a.key === preferred)
+ || (!a.entry?.seed) - (!b.entry?.seed) || (b.entry?.at || 0) - (a.entry?.at || 0));
let units = 0, count = 0;
for (const e of entries) {
- const json = e.value && JSON.stringify(e.value);
+ const max = count < FULL_FEEDS && !e.entry?.seed ? FEED_CACHE_POSTS : FEED_SEED_POSTS;
+ const value = e.entry && { at: e.entry.at, notes: compact(e.entry.notes, max), ...(e.entry.seed ? { seed: true } : {}) };
+ const json = value && JSON.stringify(value);
if (!json || count >= MAX_FEEDS || units + json.length > MAX_UNITS) storage.removeItem(e.key);
else { if (e.text !== json) storage.setItem(e.key, json); units += json.length; count++; }
}
}
+ const peek = (id) => { try { return JSON.parse(storage.getItem(keyOf(id)) || 'null'); } catch { return null; } };
return {
read(id) {
try {
@@ -49,5 +57,16 @@ export function createFeedCache(storage, prefix, now = Date.now) {
save(id, notes) {
try { storage.setItem(keyOf(id), JSON.stringify({ at: now(), notes: compact(notes) })); sweep(keyOf(id)); } catch {}
},
+ // Whether a feed already has posts on disk (without counting as a read).
+ has: (id) => !!peek(id)?.notes?.length,
+ // A few posts fetched ahead for a feed not yet opened. Never replaces
+ // posts a read left behind, and ranks below every feed that was read.
+ seed(id, notes) {
+ try {
+ if (peek(id)?.notes?.length) return;
+ storage.setItem(keyOf(id), JSON.stringify({ at: now(), seed: true, notes: compact(notes, FEED_SEED_POSTS) }));
+ sweep(null);
+ } catch {}
+ },
};
}
diff --git a/src/i18n.js b/src/i18n.js
index eff5a73..02584b2 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -198,7 +198,9 @@ const STR = {
msgAttachFailed: 'Attachment unavailable',
msgAttach: 'Attach a picture',
msgAttachTooBig: 'That file is too big (20 MB max)',
- msgUploadFailed: 'Upload failed. Try again',
+ 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',
msgPhoto: 'Photo',
searchPeopleFor: 'Search people for {q}',
msgEmojiSearch: 'Search emoji…',
@@ -242,6 +244,10 @@ const STR = {
feedTopicsHelp: 'Posts tagged with any of these. With people set as well, only their posts on these topics.',
feedDelete: 'Delete feed',
feedSaveAdhoc: 'Save feed',
+ feedSaved: 'Feed saved',
+ feedPostFab: 'Post',
+ mediaShrinking: 'Shrinking video for streaming',
+ mediaUploading: 'Uploading',
feedNoQuery: 'Add people or topics to this feed.',
feedOfTitle: '{name}’s timeline',
feedOfLoading: 'Reading who they follow…',
@@ -362,6 +368,10 @@ const STR = {
postBoostedAlready: 'You already boosted that',
postQuote: 'Quote',
postMore: 'More',
+ postDiscardTitle: 'Discard this draft?',
+ postDiscardBody: 'You’ll lose this draft and any attachments.',
+ postKeepEditing: 'Keep editing',
+ postDiscard: 'Discard draft',
postMute: 'Mute',
postUnmute: 'Unmute',
postMuted: 'Muted. Their posts are hidden',
@@ -425,6 +435,7 @@ const STR = {
msgNoDms: 'No conversations yet.',
msgShowAllDms: 'Show all {n} conversations',
msgShowFewerDms: 'Show fewer',
+ msgShowAllCommunities: 'Show all {n} communities',
msgNoDmsYet: 'No messages yet. Say hi!',
msgDmPlaceholder: 'Message',
msgDmEncrypted: 'Private message · end-to-end encrypted',
@@ -459,6 +470,11 @@ const STR = {
arkFeeService: 'Service fee',
arkCoinsExpired: 'expired',
noteForeignKind: 'A kind {kind} event this app doesn’t show.',
+ pollVoter: '{n} vote',
+ pollVoters: '{n} votes',
+ pollMulti: 'Pick any',
+ pollEnded: 'Poll closed',
+ pollEndsAt: 'Closes {when}',
arkExpiredDust: '{amount} has expired and is too small to renew. You can forget it from the Manage page.',
arkCoinForget: 'Forget',
arkCoinForgetTitle: 'Give this coin up: it has expired and is too small to renew or exit.',
@@ -999,9 +1015,9 @@ const STR = {
onbNameWait: 'Getting things ready…',
onbNotNow: 'Not now',
onbAvatarTitle: 'Choose your look',
- onbAvatarBody: 'Pick a punk or upload your own picture. You can change it anytime.',
+ onbAvatarBody: 'Choose an avatar or upload your own picture. You can change it anytime.',
onbUpload: 'Upload a photo',
- onbUploadFailed: 'Upload failed. Try a punk for now',
+ onbUploadFailed: 'Upload failed. Try another picture',
onbProfileSkipped: 'Couldn’t publish your profile. Set it later from Edit profile',
onbContinue: 'Continue',
onbSkipAvatar: 'Continue with my default',
diff --git a/src/lnurl.js b/src/lnurl.js
index f641707..3f1b39b 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -68,13 +68,26 @@ export function zapTargetFromProfile(profile, pk) {
function safeEncode(url) { try { return encodeLnurl(url); } catch { return null; } }
+// Errors name the server: the recipient's Lightning address lives on THEIR
+// host, and a bare "signal is aborted without reason" (the browser's words
+// for our timeout) read as though the wallet had broken.
async function fetchJson(url, ms = 8000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
+ let host = 'their Lightning server';
+ try { host = new URL(url).host; } catch {}
try {
const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } });
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ if (!r.ok) {
+ // LNURL servers say why in a JSON { status: 'ERROR', reason } body
+ const reason = await r.json().then((d) => d && d.reason).catch(() => null);
+ throw new Error(reason ? `${host}: ${reason}` : `${host} answered with an error (HTTP ${r.status})`);
+ }
return await r.json();
+ } catch (e) {
+ if (ctrl.signal.aborted || e?.name === 'AbortError') throw new Error(`${host} didn’t answer in ${Math.round(ms / 1000)}s — their Lightning address may be down`);
+ if (e instanceof TypeError) throw new Error(`couldn’t reach ${host}`);
+ throw e;
} finally { clearTimeout(timer); }
}
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 6763c56..2e914c3 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -616,9 +616,9 @@
"onbNameWait": "جهز الأشياء",
"onbNotNow": "ليس الآن",
"onbAvatarTitle": "اختر مظهرك",
- "onbAvatarBody": "إلتقط قطعة أو ارفع صورتك يمكنك تغييره في أي وقت",
+ "onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت",
"onbUpload": "تحميل الصورة",
- "onbUploadFailed": "لقد فشل التحميل - حاول أن تفشل الآن",
+ "onbUploadFailed": "فشل الرفع. جرّب صورة أخرى",
"onbProfileSkipped": "لم يتمكن من نشر ملفك - وضعه في وقت لاحق من ملف إديت",
"onbContinue": "استمر",
"onbSkipAvatar": "استمر في إخفاقي",
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 8c52809..44b2700 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -616,9 +616,9 @@
"onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...",
"onbNotNow": "এই মুহূর্তে নয়",
"onbAvatarTitle": "আপনার চেহারা পছন্দ করুন",
- "onbAvatarBody": "তোমার নিজের ছবি আপলোড করে নাও। তুমি যেকোন সময় এটাকে পরিবর্তন করতে পারো।",
+ "onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।",
"onbUpload": "একটি ছবি আপলোড করুন",
- "onbUploadFailed": "আপলোড করতে ব্যর্থ - এখন একটা বাজে প্রচেষ্টা করুন",
+ "onbUploadFailed": "আপলোড ব্যর্থ হয়েছে। অন্য একটি ছবি চেষ্টা করো",
"onbProfileSkipped": "প্রোফাইল সম্পাদনা করতে ব্যর্থ - পরে নির্ধারণ করা সম্ভব নয়",
"onbContinue": "এগিয়ে চলুন",
"onbSkipAvatar": "ডিফল্ট হিসেবে নির্দিষ্ট করো",
diff --git a/src/locales/de.json b/src/locales/de.json
index 6409d81..bf827b4 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -616,9 +616,9 @@
"onbNameWait": "Bereitmachen...",
"onbNotNow": "Nicht jetzt",
"onbAvatarTitle": "Wählen Sie Ihren Look",
- "onbAvatarBody": "Wählen Sie einen Punk oder laden Sie Ihr eigenes Bild hoch. Sie können es jederzeit ändern.",
+ "onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.",
"onbUpload": "Foto hochladen",
- "onbUploadFailed": "Upload fehlgeschlagen Versuchen Sie einen Punk für jetzt",
+ "onbUploadFailed": "Upload fehlgeschlagen. Versuchen Sie ein anderes Bild",
"onbProfileSkipped": "Konnte dein Profil nicht publizieren",
"onbContinue": "Weiter",
"onbSkipAvatar": "Weiter mit meinem Standard",
diff --git a/src/locales/es.json b/src/locales/es.json
index 6cf00b6..52e417b 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -616,9 +616,9 @@
"onbNameWait": "Preparando las cosas...",
"onbNotNow": "Ahora no.",
"onbAvatarTitle": "Elige tu look",
- "onbAvatarBody": "Escoge un punk o sube tu propia imagen. Puedes cambiarlo en cualquier momento.",
+ "onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.",
"onbUpload": "Sube una foto",
- "onbUploadFailed": "Subir falló, probar un punk por ahora",
+ "onbUploadFailed": "La subida falló. Prueba con otra imagen",
"onbProfileSkipped": "No se pudo publicar su perfil - establecerlo más tarde del perfil Editar",
"onbContinue": "Continuar",
"onbSkipAvatar": "Continuar con mi defecto",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 9dd56c0..471eb5b 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -616,9 +616,9 @@
"onbNameWait": "Préparer les choses...",
"onbNotNow": "Pas maintenant.",
"onbAvatarTitle": "Choisissez votre look",
- "onbAvatarBody": "Choisissez un punk ou téléchargez votre propre photo. Tu peux le changer à tout moment.",
+ "onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.",
"onbUpload": "Télécharger une photo",
- "onbUploadFailed": "Télécharger échoué, essayez un punk pour l'instant",
+ "onbUploadFailed": "Le téléversement a échoué. Essayez une autre image",
"onbProfileSkipped": "Impossible de publier votre profil, définissez-le plus tard à partir du profil Édition",
"onbContinue": "Continuer",
"onbSkipAvatar": "Continuer avec mon défaut",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index be3e6a3..4fcd47d 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -616,9 +616,9 @@
"onbNameWait": "चीजों को तैयार करना",
"onbNotNow": "अब नहीं",
"onbAvatarTitle": "अपना लुक चुनें",
- "onbAvatarBody": "एक पंक चुनें या अपनी खुद की तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।",
+ "onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।",
"onbUpload": "फ़ोटो अपलोड करें",
- "onbUploadFailed": "असफल रहा - अब के लिए एक पंक का प्रयास करें",
+ "onbUploadFailed": "अपलोड विफल रहा। कोई दूसरी तस्वीर आज़माएँ",
"onbProfileSkipped": "अपने प्रोफ़ाइल को प्रकाशित नहीं कर सका - इसे बाद में संपादित प्रोफ़ाइल से सेट करें",
"onbContinue": "जारी",
"onbSkipAvatar": "जारी रखें मेरी डिफ़ॉल्ट के साथ",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index d9cd967..2ab8916 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -616,9 +616,9 @@
"onbNameWait": "物事を準備する...",
"onbNotNow": "今は今",
"onbAvatarTitle": "お問い合わせ",
- "onbAvatarBody": "パンクを選ぶか、自分の写真をアップロードしてください。 いつでも変更できます。",
+ "onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。",
"onbUpload": "写真をアップロードする",
- "onbUploadFailed": "アップロード失敗, 今すぐパンクを試してみる",
+ "onbUploadFailed": "アップロードに失敗しました。別の画像をお試しください",
"onbProfileSkipped": "プロフィールを公開できません。編集プロファイルから後で設定できます。",
"onbContinue": "お問い合わせ",
"onbSkipAvatar": "デフォルトで続ける",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 6413666..e125415 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -615,9 +615,9 @@
"onbNameWait": "준비하는 것들 ...",
"onbNotNow": "현재 위치",
"onbAvatarTitle": "자주 묻는 질문",
- "onbAvatarBody": "펑크를 선택하거나 자신의 사진을 업로드하십시오. 언제든지 변경할 수 있습니다.",
+ "onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.",
"onbUpload": "사진 업로드",
- "onbUploadFailed": "업로드 실패, 지금 punk 시도",
+ "onbUploadFailed": "업로드하지 못했습니다. 다른 이미지를 사용해 보세요",
"onbProfileSkipped": "프로필을 게시 할 수 없습니다 - 나중에 편집 프로필에서 설정",
"onbContinue": "계속하기",
"onbSkipAvatar": "기본 설정",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 8c36922..9f5b2ad 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -616,9 +616,9 @@
"onbNameWait": "Preparando as coisas...",
"onbNotNow": "Agora não.",
"onbAvatarTitle": "Escolha o seu look",
- "onbAvatarBody": "Escolha um punk ou carregue a sua própria foto. Podes mudá-lo quando quiseres.",
+ "onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.",
"onbUpload": "Enviar uma foto",
- "onbUploadFailed": "O envio falhou, tente um punk por enquanto",
+ "onbUploadFailed": "O envio falhou. Tente outra imagem",
"onbProfileSkipped": "Não foi possível publicar o seu perfil, defini-lo mais tarde a partir de Editar perfil",
"onbContinue": "Continuar",
"onbSkipAvatar": "Continuar com o meu padrão",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 7ea2c0f..4e472fc 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -616,9 +616,9 @@
"onbNameWait": "Готовить вещи...",
"onbNotNow": "Не сейчас",
"onbAvatarTitle": "Выберите свой взгляд",
- "onbAvatarBody": "Выберите панк или загрузите свою фотографию. Вы можете изменить его в любое время.",
+ "onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.",
"onbUpload": "Загрузить фото",
- "onbUploadFailed": "Загрузить не удалось, попробуйте панк на данный момент",
+ "onbUploadFailed": "Не удалось загрузить. Попробуйте другое изображение",
"onbProfileSkipped": "Не смог опубликовать свой профиль, установите его позже из профиля Edit",
"onbContinue": "Продолжать",
"onbSkipAvatar": "Продолжить мой дефолт",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 1f261b7..e1e9928 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -615,9 +615,9 @@
"onbNameWait": "İşler hazır olsun...",
"onbNotNow": "Şimdi Değil",
"onbAvatarTitle": "görünümünü seçin",
- "onbAvatarBody": "Bir punk seçin veya kendi resmini yükleyin. Her zaman değiştirebilirsiniz.",
+ "onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.",
"onbUpload": "Bir fotoğraf",
- "onbUploadFailed": "Yükleme başarısız oldu - şimdilik bir punk deneyin",
+ "onbUploadFailed": "Yükleme başarısız oldu. Başka bir resim deneyin",
"onbProfileSkipped": "Profilinizi yayınlayamaz - daha sonra Edit profilinden ayarlayın",
"onbContinue": "Devam etmeye devam et",
"onbSkipAvatar": "Benim varsayılanimle devam et",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 04748fc..6bb4c50 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -616,9 +616,9 @@
"onbNameWait": "准备...",
"onbNotNow": "现在不行",
"onbAvatarTitle": "选择你的外观",
- "onbAvatarBody": "选择一个朋克或上传自己的相片. 你可以随时改变",
+ "onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改",
"onbUpload": "上传相片",
- "onbUploadFailed": "上传失败,, 暂时尝试一个朋克",
+ "onbUploadFailed": "上传失败。请尝试其他图片",
"onbProfileSkipped": "无法发布您的配置文件, 稍后从编辑配置文件中设置 。",
"onbContinue": "继续",
"onbSkipAvatar": "继续我的默认",
diff --git a/src/media-upload.js b/src/media-upload.js
new file mode 100644
index 0000000..c06771b
--- /dev/null
+++ b/src/media-upload.js
@@ -0,0 +1,117 @@
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex } from '@noble/hashes/utils';
+
+// Independent public hosts: a phone upload should not fail just because one
+// provider is down or rejects a particular container. Order from a
+// 2026-09-30 benchmark (4 MB mp4 / 110 KB jpeg, uploaded then fetched):
+// ditto took the mp4 in ~0.6 s and serves in ~120 ms; primal ~1.5 s; nostr.
+// download ~1-2 s but refuses a JPEG with bytes after its end marker (motion
+// photos); blossom.nostr.build ~5 s. The first to accept the file wins.
+export const PUBLIC_MEDIA_SERVERS = ['https://blossom.ditto.pub', 'https://blossom.primal.net',
+ 'https://nostr.download', 'https://blossom.nostr.build'];
+
+export class MediaUploadError extends Error {
+ constructor(code, details = '') {
+ super(code);
+ this.name = 'MediaUploadError';
+ this.code = code;
+ this.details = details;
+ }
+}
+
+async function fileHash(file) {
+ const hash = sha256.create();
+ const chunk = 4 * 1024 * 1024;
+ for (let at = 0; at < file.size; at += chunk)
+ hash.update(new Uint8Array(await file.slice(at, at + chunk).arrayBuffer()));
+ return bytesToHex(hash.digest());
+}
+
+const responseUrl = (body) => body?.url
+ || body?.data?.[0]?.url
+ || (body?.tags || []).find((x) => x?.[0] === 'url')?.[1]
+ || null;
+
+function mediaUrl(url, server, file) {
+ const out = new URL(url, server);
+ const tail = out.pathname.split('/').at(-1) || '';
+ if (/^[0-9a-f]{64}$/i.test(tail)) {
+ const named = String(file.name || '').match(/\.([a-z0-9]{1,8})$/i)?.[1];
+ const byType = { 'video/quicktime': 'mov', 'video/mp4': 'mp4', 'video/webm': 'webm',
+ 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }[file.type];
+ const ext = (named || byType || '').toLowerCase();
+ if (ext) out.pathname += '.' + ext;
+ }
+ return out.href;
+}
+
+// A PUT that reports how much of the body has gone out: fetch() can't, so in
+// the browser (with the real fetch) it's XMLHttpRequest. Answers like fetch.
+function putWithProgress(url, { body, headers, signal }, onProgress) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('PUT', url);
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
+ xhr.onload = () => resolve(new Response(xhr.responseText, {
+ status: xhr.status, headers: { 'x-reason': xhr.getResponseHeader('x-reason') || '' } }));
+ xhr.onerror = () => reject(new Error('network error'));
+ xhr.ontimeout = () => reject(new Error('timeout'));
+ if (signal) signal.addEventListener('abort', () => { xhr.abort(); reject(new Error('timeout')); });
+ xhr.send(body);
+ });
+}
+
+// BUD-02 upload. `signEvent` is deliberately injected: the app can use its
+// always-available wallet identity, while tests need no secret key.
+export async function uploadPublicMedia(file, signEvent, {
+ servers = PUBLIC_MEDIA_SERVERS,
+ fetcher = globalThis.fetch,
+ timeoutMs = 5 * 60_000,
+ onProgress = null, // (fraction 0..1) as the bytes go out
+} = {}) {
+ const tracked = onProgress && typeof XMLHttpRequest !== 'undefined' && fetcher === globalThis.fetch;
+ if (!file || !file.size || typeof signEvent !== 'function') throw new MediaUploadError('failed');
+ const x = await fileHash(file);
+ const now = Math.floor(Date.now() / 1000);
+ const auth = await signEvent({
+ kind: 24242, created_at: now - 5, content: 'upload',
+ tags: [['t', 'upload'], ['x', x], ['expiration', String(now + Math.ceil(timeoutMs / 1000) + 120)]],
+ });
+ if (!auth) throw new MediaUploadError('failed');
+ const authorization = 'Nostr ' + btoa(JSON.stringify(auth));
+ const failures = [];
+ for (const [i, server] of servers.entries()) {
+ // A host that doesn't answer at all costs a few seconds, not the whole
+ // 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; }
+ }
+ try {
+ const init = {
+ method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs),
+ headers: {
+ authorization,
+ 'content-type': file.type || 'application/octet-stream',
+ 'x-sha-256': x,
+ 'x-content-length': String(file.size),
+ },
+ };
+ if (onProgress) onProgress(0);
+ const target = server.replace(/\/$/, '') + '/upload';
+ const response = tracked ? await putWithProgress(target, init, onProgress) : await fetcher(target, init);
+ let body = null;
+ 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 || '' });
+ } catch (error) {
+ failures.push({ status: 0, reason: error?.message || '' });
+ }
+ }
+ const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; ');
+ 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);
+}
diff --git a/src/nostr.js b/src/nostr.js
index 30ddf85..dcf4bbc 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -117,6 +117,7 @@ const SICK_MS = 10 * 60_000;
const SICK_MAX_MS = 24 * 60 * 60_000;
const SICK_KEY = 'coinos-sick-relays';
const relayKey = (u) => { try { return normalizeURL(u); } catch { return u; } };
+const HOME_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.damus.io', 'wss://relay.primal.net'];
const sick = new Map(); // normalized url -> { until, n }
try {
const saved = JSON.parse(localStorage.getItem(SICK_KEY) || '{}');
@@ -125,12 +126,22 @@ try {
const saveSick = () => {
try { localStorage.setItem(SICK_KEY, JSON.stringify(Object.fromEntries([...sick].slice(-200)))); } catch {}
};
+// The relays the app stands on are never written off for long: a phone
+// asleep or between networks fails every socket at once, and an escalating
+// verdict on relay.coinos.io then kept a profile at the 2 posts primal still
+// held for hours after the network was back (2026-10-01).
+const HOME_SICK_MS = 60_000;
+const isHomeRelay = (n) => HOME_RELAYS.some((u) => relayKey(u) === n);
+// Failures while the tab is hidden or offline say nothing about the relay.
+const offline = () => typeof navigator !== 'undefined' && navigator.onLine === false;
+const hiddenTab = () => typeof document !== 'undefined' && document.visibilityState === 'hidden';
export function markRelaySick(url, why) {
if (!url) return;
const n = relayKey(url);
+ if (offline() || hiddenTab()) { resetRelay(url); return; }
const prev = sick.get(n);
const strikes = prev ? (prev.n || 1) + 1 : 1;
- const ms = Math.min(SICK_MAX_MS, SICK_MS * 2 ** (strikes - 1));
+ const ms = isHomeRelay(n) ? HOME_SICK_MS : Math.min(SICK_MAX_MS, SICK_MS * 2 ** (strikes - 1));
if (!prev) dlog(`nostr: ${url} unreachable (${why || 'connect failed'}) — skipping for ${Math.round(ms / 60_000)} min`);
sick.set(n, { until: Date.now() + ms, n: strikes });
saveSick();
@@ -139,6 +150,18 @@ export function markRelaySick(url, why) {
resetRelay(url);
}
const isSick = (u) => sick.has(relayKey(u));
+// Back online or back on screen: the home relays get a fresh chance at once
+// (verdicts saved by older builds could still be hours long).
+function forgiveHomeRelays() {
+ let changed = false;
+ for (const u of [...sick.keys()]) if (isHomeRelay(u)) { sick.delete(u); changed = true; }
+ if (changed) saveSick();
+}
+forgiveHomeRelays();
+if (typeof window !== 'undefined') {
+ window.addEventListener('online', forgiveHomeRelays);
+ document.addEventListener('visibilitychange', () => { if (!hiddenTab()) forgiveHomeRelays(); });
+}
// Drop known-bad relays, but never hand back an empty set: if everything is
// sick the network probably is, and one doomed attempt beats doing nothing.
function liveRelays(list) {
@@ -301,7 +324,7 @@ export function nsecOf(sk) { try { return nsecEncode(sk); } catch { return null;
// against a real 862-follow list: nos.lol carries 267 of them, damus.io 249,
// primal 170 — and damus wasn't here, which left 232 follows with no relay
// list of their own leaning on two relays instead of three.
-export const PROFILE_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.damus.io', 'wss://relay.primal.net'];
+export const PROFILE_RELAYS = HOME_RELAYS;
// Fetch a recipient's profile (name + picture) for a pubkey, newest across relays.
export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) {
let events;
diff --git a/src/rich-text.js b/src/rich-text.js
new file mode 100644
index 0000000..7003f5c
--- /dev/null
+++ b/src/rich-text.js
@@ -0,0 +1,181 @@
+// <coinos-text>: a plain-text field built on contenteditable, standing in
+// for <textarea> in the composers.
+//
+// Why: Android keyboards (Gboard's GIFs, stickers, image suggestions) only
+// offer pictures to a field that says it accepts them, and Chrome says so
+// for contenteditable — never for <textarea>, where the GIF key sits greyed
+// out with "This app does not support images here". So the field is
+// editable HTML that behaves, to the code around it, like a textarea:
+// `value`, `selectionStart`/`selectionEnd` (read and write),
+// `setSelectionRange`, a `placeholder`, Enter handled by the page. Text
+// stays plain — pasted formatting is dropped — and any picture that arrives
+// (a keyboard GIF, a pasted or dropped image) is handed to `onmedia(file)`
+// as a File, the same path as the paperclip, instead of landing in the text.
+
+const BLOCK = new Set(['DIV', 'P', 'LI']);
+
+// The field's text, newlines where the editor put <br> or a new block.
+function textOf(root) {
+ let out = '';
+ const walk = (n) => {
+ for (const c of n.childNodes) {
+ if (c.nodeType === 3) out += c.data;
+ else if (c.nodeName === 'BR') out += '\n';
+ else if (c.nodeType === 1) {
+ if (BLOCK.has(c.nodeName) && out && !out.endsWith('\n')) out += '\n';
+ walk(c);
+ }
+ }
+ };
+ walk(root);
+ // an editor leaves a lone trailing <br> to hold an empty last line open
+ return out.endsWith('\n') && root.lastChild && root.lastChild.nodeName === 'BR' ? out.slice(0, -1) : out;
+}
+
+// Text length from the start of `root` up to (node, offset).
+function offsetOf(root, node, offset) {
+ const r = document.createRange();
+ r.selectNodeContents(root);
+ try { r.setEnd(node, offset); } catch { return textOf(root).length; }
+ const frag = r.cloneContents();
+ const div = document.createElement('div');
+ div.append(frag);
+ let t = textOf(div);
+ // a <br> right at the caret counts as the newline it is
+ if (div.lastChild && div.lastChild.nodeName === 'BR') t += '\n';
+ return t.length;
+}
+
+// (node, offset) for a text position; the field is normalised to text nodes
+// and <br>s, so that is all this walks.
+function pointAt(root, pos) {
+ let left = pos;
+ for (const c of root.childNodes) {
+ if (c.nodeType === 3) {
+ if (left <= c.data.length) return [c, left];
+ left -= c.data.length;
+ } else if (c.nodeName === 'BR') {
+ if (left === 0) return [root, [...root.childNodes].indexOf(c)];
+ left -= 1;
+ }
+ }
+ return [root, root.childNodes.length];
+}
+
+const mediaOf = (dt) => [...((dt && dt.files) || [])].filter((f) => /^(image|video)\//.test(f.type));
+
+class CoinosText extends HTMLElement {
+ constructor() {
+ super();
+ this._pendingValue = null;
+ this.addEventListener('paste', (e) => this._onPaste(e));
+ this.addEventListener('drop', (e) => {
+ const files = mediaOf(e.dataTransfer);
+ if (files.length) { e.preventDefault(); this._media(files); }
+ });
+ this.addEventListener('beforeinput', (e) => this._onBeforeInput(e));
+ this.addEventListener('input', () => this._tidy());
+ }
+ connectedCallback() {
+ if (!this.hasAttribute('contenteditable')) this.setAttribute('contenteditable', 'true');
+ this.setAttribute('role', 'textbox');
+ this.setAttribute('aria-multiline', 'true');
+ if (this.hasAttribute('placeholder')) this.setAttribute('aria-placeholder', this.getAttribute('placeholder'));
+ this._empty();
+ }
+
+ get value() { return textOf(this); }
+ set value(v) {
+ v = v == null ? '' : String(v);
+ if (v === this.value) return;
+ // text nodes and <br>s only: what pointAt walks
+ this.replaceChildren(...v.split('\n').flatMap((line, i) => [...(i ? [document.createElement('br')] : []), ...(line ? [document.createTextNode(line)] : [])]));
+ if (v.endsWith('\n')) this.append(document.createElement('br'));
+ this._empty();
+ }
+ get placeholder() { return this.getAttribute('placeholder') || ''; }
+ set placeholder(v) { this.setAttribute('placeholder', v); }
+
+ get selectionStart() { return this._sel()[0]; }
+ get selectionEnd() { return this._sel()[1]; }
+ set selectionStart(v) { const [, e] = this._sel(); this.setSelectionRange(v, Math.max(v, e)); }
+ set selectionEnd(v) { const [s] = this._sel(); this.setSelectionRange(Math.min(s, v), v); }
+ _sel() {
+ const sel = document.getSelection();
+ if (!sel || !sel.rangeCount || !this.contains(sel.anchorNode)) { const n = this.value.length; return [n, n]; }
+ const r = sel.getRangeAt(0);
+ return [offsetOf(this, r.startContainer, r.startOffset), offsetOf(this, r.endContainer, r.endOffset)];
+ }
+ setSelectionRange(start, end = start) {
+ this.normalize();
+ const sel = document.getSelection();
+ if (!sel) return;
+ const r = document.createRange();
+ const [sn, so] = pointAt(this, start);
+ const [en, eo] = pointAt(this, end);
+ try { r.setStart(sn, so); r.setEnd(en, eo); sel.removeAllRanges(); sel.addRange(r); } catch {}
+ }
+
+ // Insert text at the caret as the editor would (undo history intact).
+ _insert(text) {
+ if (!document.execCommand('insertText', false, text)) {
+ const s = this.selectionStart, e = this.selectionEnd, v = this.value;
+ this.value = v.slice(0, s) + text + v.slice(e);
+ this.setSelectionRange(s + text.length);
+ this.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }
+ _media(files) {
+ for (const f of files) {
+ if (typeof this.onmedia === 'function') this.onmedia(f);
+ }
+ }
+ _onPaste(e) {
+ const dt = e.clipboardData;
+ const files = mediaOf(dt);
+ if (files.length) { e.preventDefault(); this._media(files); return; }
+ // plain text only: no fonts, links or colours from wherever it was copied
+ e.preventDefault();
+ const text = dt ? dt.getData('text/plain') : '';
+ if (text) this._insert(text.replace(/\r\n?/g, '\n'));
+ }
+ _onBeforeInput(e) {
+ // keyboard-inserted pictures (Android's commitContent) and drops arrive
+ // as insertions carrying a DataTransfer
+ if (e.dataTransfer) {
+ const files = mediaOf(e.dataTransfer);
+ if (files.length) { e.preventDefault(); this._media(files); return; }
+ if (/^insertFrom/.test(e.inputType)) {
+ e.preventDefault();
+ const text = e.dataTransfer.getData('text/plain');
+ if (text) this._insert(text.replace(/\r\n?/g, '\n'));
+ return;
+ }
+ }
+ // a new line is a newline, not a new <div>
+ if (e.inputType === 'insertParagraph') { e.preventDefault(); this._insert('\n'); return; }
+ if (/^format/.test(e.inputType)) e.preventDefault(); // no bold/italic shortcuts
+ }
+ // Anything that isn't text or <br> (an <img> a keyboard managed to put
+ // in, a styled span) is taken out; a picture goes to onmedia.
+ _tidy() {
+ for (const img of this.querySelectorAll('img')) {
+ const src = img.getAttribute('src');
+ img.remove();
+ if (src) fetch(src).then((r) => r.blob()).then((b) => {
+ if (/^(image|video)\//.test(b.type)) this._media([new File([b], 'pasted.' + (b.type.split('/')[1] || 'png'), { type: b.type })]);
+ }).catch(() => {});
+ }
+ if ([...this.children].some((c) => c.nodeName !== 'BR')) {
+ const s = this.selectionStart, v = this.value;
+ this.value = v;
+ if (document.activeElement === this) this.setSelectionRange(Math.min(s, v.length));
+ }
+ this._empty();
+ }
+ _empty() { this.toggleAttribute('data-empty', !this.value); }
+}
+
+if (typeof customElements !== 'undefined' && !customElements.get('coinos-text')) {
+ customElements.define('coinos-text', CoinosText);
+}
diff --git a/src/style.css b/src/style.css
index 2facddc..91b8dd4 100644
--- a/src/style.css
+++ b/src/style.css
@@ -248,6 +248,21 @@ select {
background-repeat: no-repeat; background-position: right 14px center;
}
textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-size: 15px; }
+/* The composers' text field (src/rich-text.js): a textarea to look at and
+ type in, contenteditable underneath so keyboards offer GIFs and images. */
+coinos-text {
+ display: block; width: 100%; box-sizing: border-box; font: inherit; color: var(--ink); background: var(--paper);
+ border: 1px solid var(--line-strong); border-radius: 11px; padding: 11px 13px;
+ white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; line-height: 1.4; cursor: text;
+}
+coinos-text:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
+/* a picture or video on its way: shrinking, then uploading (mediaBar) */
+.media-progress { display: flex; flex-direction: column; gap: 4px; }
+.media-progress-pct { font-variant-numeric: tabular-nums; }
+.media-progress-track { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
+.media-progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width .2s linear; }
+coinos-text[data-empty]::before { content: attr(placeholder); color: var(--muted); pointer-events: none; }
+.chat-compose coinos-text { flex: 1; min-width: 0; padding: 10px 13px; max-height: 120px; }
.mono-input { font-family: var(--mono); }
.input-group { display: flex; gap: 8px; }
.input-group input { flex: 1; }
@@ -626,7 +641,12 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
/* The row under a post: reply, boost, quote, react, zap — spread across the
post's full width so each is a comfortable target, quiet until one carries
a count or you've used it. */
-.note-acts { margin-top: 8px; gap: 4px; width: min(100%, 340px); }
+.note-post { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 8px 10px; align-items: center; }
+.note-content { display: contents; }
+.note-content > * { grid-column: 1 / -1; min-width: 0; }
+.note-content > .note-header { grid-column: 2; grid-row: 1; }
+.note-content > .note-text { font-size: 18px; line-height: 1.5; }
+.note-acts { margin-top: 8px; gap: 4px; width: 100%; }
.note-acts > .note-act { flex: 1 1 0; min-width: 0; justify-content: center; padding-inline: 2px; }
/* server comparison table (Settings → Advanced → Ark server) */
.cmp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
@@ -707,7 +727,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
four don't fit across, the row wraps a whole button to the next line */
.prof-actions > button { white-space: nowrap; }
/* the field inside the panel: a quiet hairline, the panel is the frame */
-.thread-reply .thread-reply-input { border: 1px solid var(--line); background: var(--surface, #fff); border-radius: 10px; }
+.thread-reply .thread-reply-input { border: 1px solid var(--line); background: var(--paper); color: var(--ink); border-radius: 10px; }
.thread-reply .thread-reply-input:focus { border-color: var(--ink); outline: none; }
/* Cancel and Reply: one size, the icon buttons' height */
.thread-reply .reply-tools .thread-reply-cancel, .thread-reply .reply-tools .thread-reply-send {
@@ -721,6 +741,7 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
display: flex; align-items: center; justify-content: center;
background: rgba(8, 10, 14, .94); padding: 16px;
animation: lightbox-in .12s ease-out;
+ touch-action: none; /* pinch and drag are the picture's own (see attachZoom) */
}
/* Nothing scrolls behind a picture that fills the screen — and the reserved
scrollbar gutter (see html, top of file) stands down with it, or it shows
@@ -732,6 +753,7 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.lightbox img {
max-width: 100%; max-height: 100svh; max-height: 100vh;
object-fit: contain; border-radius: 6px; cursor: zoom-out;
+ user-select: none; -webkit-user-select: none; transform-origin: center center; will-change: transform;
}
.lightbox-x {
position: absolute; top: max(10px, env(safe-area-inset-top)); right: 10px;
@@ -761,10 +783,63 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.quote-card.clickable { cursor: pointer; }
.quote-card.clickable:hover { border-color: var(--line-strong); }
.quote-loading { flex-direction: row; align-items: center; gap: 8px; }
+/* A poll (NIP-88): each option a row with its share of the vote as a bar
+ behind the label. Only the bar's width changes as votes arrive, so the
+ post never changes height under the reader. */
+.poll { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
+.poll-opt {
+ position: relative; overflow: hidden; display: flex; align-items: center; gap: 8px;
+ width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px;
+ background: var(--surface2); color: inherit; font: inherit; text-align: left; cursor: pointer;
+}
+.poll-opt.static { cursor: inherit; min-height: 34px; padding: 6px 10px; }
+.poll-opt:not(.static):hover { border-color: var(--line-strong); }
+.poll-opt.mine { border-color: var(--accent); }
+.poll-bar { position: absolute; inset: 0 auto 0 0; background: var(--line); transition: width .3s ease; pointer-events: none; }
+.poll-opt.mine .poll-bar { background: var(--line-strong); }
+.poll-label { position: relative; flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.poll-opt.mine .poll-label { font-weight: 600; }
+.poll-pct { position: relative; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 14px; }
+.poll-foot { margin-top: 2px; }
/* Posts that landed while you were reading further down. Floats over the
feed near the top, out of the way of the thumb, and takes you up to them
when tapped. Fixed, not sticky: the feed is the page scroller, and a
sticky element inside the card would scroll away with the card. */
+#app:has(> .app-bottom-nav) { padding-bottom: calc(82px + env(safe-area-inset-bottom, 0px)); }
+.app-bottom-nav {
+ /* Edge to edge like a system bar; the buttons stay over the page column. */
+ position: fixed; bottom: 0; left: 0; right: 0;
+ z-index: 30; display: flex; justify-content: center; box-sizing: border-box;
+ padding: 6px max(4px, calc((100% - 560px) / 2)) calc(6px + env(safe-area-inset-bottom, 0px));
+ background: var(--paper); border-top: 1px solid var(--line);
+ box-shadow: 0 -4px 18px rgba(0, 0, 0, .06);
+}
+.app-nav-button {
+ display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center;
+ justify-content: center; gap: 3px; min-height: 52px; padding: 4px;
+ border: 0; border-radius: 12px; background: transparent; color: var(--muted); font-size: 11px;
+ cursor: pointer; transition: color .15s ease, background-color .15s ease;
+}
+@media (hover: hover) { .app-nav-button:hover { color: var(--ink); } }
+.app-nav-button > span:first-child { display: flex; position: relative; }
+.app-nav-button[aria-current="page"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
+.app-nav-button.unread > span:first-child::after {
+ content: ''; position: absolute; top: -2px; right: -4px; width: 7px; height: 7px;
+ border-radius: 50%; background: var(--green); border: 1px solid var(--paper);
+}
+.feed-post-fab {
+ /* the "write a post" button floats over the feed's bottom right, above
+ the nav, inside the page column */
+ position: fixed; z-index: 25;
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 82px);
+ right: max(16px, calc((100% - 560px) / 2 + 16px));
+ display: inline-flex; align-items: center; gap: 7px; width: auto;
+ padding: 12px 18px; border-radius: 999px; font-weight: 600;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .22);
+}
+.feed-page .feed-new-pill {
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 82px);
+}
.feed-new-pill {
/* Rides at the bottom of the window, where a thumb already is; the tap
takes the reader up to the new posts. */
@@ -873,6 +948,15 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
border-left: none; border-right: none; border-radius: 0;
box-shadow: none;
}
+/* A post that arrived while you were reading wears a tint until it has been
+ on screen a couple of seconds; then it fades to the plain page. The side
+ shadows carry the tint through the list's padding to its edges. */
+.notes-feed > .row { transition: background-color .9s ease, box-shadow .9s ease; }
+.notes-feed > .row.note-fresh {
+ --fresh-bg: color-mix(in srgb, var(--green) 9%, var(--paper));
+ background-color: var(--fresh-bg);
+ box-shadow: -14px 0 0 var(--fresh-bg), 14px 0 0 var(--fresh-bg);
+}
/* Inline move-money panel on the balance card. */
diff --git a/src/thread-cache.js b/src/thread-cache.js
index e11660e..fca64e6 100644
--- a/src/thread-cache.js
+++ b/src/thread-cache.js
@@ -4,12 +4,18 @@
const KEY = 'btc-wallet-note-threads';
const MAX_THREADS = 12;
const MAX_EVENTS = 160;
+const MAX_QUOTES = 24; // posts quoted inside the thread, so a reload paints them too
const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage
-const validNote = (e) => e?.kind === 1 && typeof e.id === 'string'
+// Any kind: a thread can hang off a poll (1068), a picture (20), a comment
+// (1111)… — requiring kind 1 left those threads uncached, so every reload
+// sat on a spinner while the relays answered.
+const validNote = (e) => Number.isSafeInteger(e?.kind) && e.kind >= 0 && typeof e.id === 'string'
&& /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string'
&& /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string'
&& Number.isFinite(e.created_at) && Array.isArray(e.tags)
&& e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string'));
+// A quote can be any kind of event (a poll, an article…), not only a note.
+const validQuote = validNote;
const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags });
const savedProfile = (p) => {
if (!p || typeof p !== 'object') return null;
@@ -46,7 +52,7 @@ export function createThreadStore(storage) {
find(id) {
return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null;
},
- save(thread, focusId, profiles = {}, counts = {}) {
+ save(thread, focusId, profiles = {}, counts = {}, quotes = []) {
if (!validNote(thread.root)) return;
const events = new Map([thread.root, ...thread.replies.filter(validNote)].map((e) => [e.id, e]));
const keep = new Map([[thread.root.id, thread.root]]);
@@ -65,7 +71,9 @@ export function createThreadStore(storage) {
visit(e.id);
}
const previous = read().find((c) => c.rootId === thread.root.id);
- const authors = [...new Set([...keep.values()].map((e) => e.pubkey))].slice(0, 24);
+ const quoteMap = new Map([...(previous?.quotes || []), ...quotes].filter(validQuote).map((e) => [e.id, e]));
+ const kept = [...quoteMap.values()].slice(-MAX_QUOTES);
+ const authors = [...new Set([...keep.values(), ...kept].map((e) => e.pubkey))].slice(0, 24 + MAX_QUOTES);
const faces = {};
for (const pk of authors) {
const fresh = savedProfile(profiles[pk]);
@@ -83,7 +91,8 @@ export function createThreadStore(storage) {
}
const entry = { rootId: thread.root.id, root: savedNote(thread.root),
profiles: faces, counts: tallies,
- replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote) };
+ replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote),
+ quotes: kept.map(savedNote) };
const entries = [entry, ...read().filter((c) => c.rootId !== entry.rootId)].slice(0, MAX_THREADS);
let json = JSON.stringify(entries);
while (json.length > MAX_BYTES && entries.length > 1) { entries.pop(); json = JSON.stringify(entries); }
diff --git a/src/video-compress.js b/src/video-compress.js
new file mode 100644
index 0000000..53d4eb0
--- /dev/null
+++ b/src/video-compress.js
@@ -0,0 +1,54 @@
+// A phone's video, made light enough to stream before it's uploaded.
+//
+// A phone records 1080p–4K at 15–20 Mbps; played back from a Blossom host
+// over mobile data that stalls every few seconds, and none of the public
+// hosts transcode (nostr.build's and primal's BUD-05 /media endpoints hand
+// back the same bitrate — measured 2026-09-30). So the phone does it:
+// WebCodecs on the device's hardware encoder, via Mediabunny (loaded only
+// when a video is picked), to H.264 at 720p and ~2.5 Mbps, the moov atom up
+// front so playback starts before the download ends. Audio passes through
+// untouched when the container allows (phones record AAC already).
+//
+// Anything short of a clear win — no WebCodecs, an unreadable file, an
+// encoder error, a result not meaningfully smaller — uploads the original,
+// exactly as before.
+
+const SHORT_EDGE = 720;
+const VIDEO_BPS = 2_500_000;
+
+export async function compressVideo(file, onProgress = () => {}) {
+ if (!file || !/^video\//.test(file.type || '')) return file;
+ if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return file;
+ let mb;
+ try { mb = await import('mediabunny'); } catch { return file; }
+ const { Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, ALL_FORMATS } = mb;
+ const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
+ try {
+ const video = await input.getPrimaryVideoTrack();
+ if (!video) return file;
+ const seconds = await input.computeDuration();
+ const w = video.displayWidth, h = video.displayHeight;
+ const short = Math.min(w, h);
+ const bps = seconds > 0 ? (file.size * 8) / seconds : Infinity;
+ // already light: nothing to gain, and a re-encode only loses quality
+ if (short <= SHORT_EDGE && bps <= VIDEO_BPS * 1.4) return file;
+ const size = short > SHORT_EDGE ? (w >= h ? { height: SHORT_EDGE } : { width: SHORT_EDGE }) : {};
+ const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() });
+ const conversion = await Conversion.init({
+ input, output,
+ video: { ...size, codec: 'avc', bitrate: VIDEO_BPS, forceTranscode: true },
+ });
+ if (!conversion.isValid) return file;
+ conversion.onProgress = (p) => { try { onProgress(p); } catch {} };
+ await conversion.execute();
+ const buf = output.target.buffer;
+ // not worth it unless it's clearly smaller
+ if (!buf || buf.byteLength > file.size * 0.8) return file;
+ const name = String(file.name || 'video').replace(/\.[a-z0-9]{1,8}$/i, '') + '.mp4';
+ return new File([buf], name, { type: 'video/mp4' });
+ } catch {
+ return file;
+ } finally {
+ try { input.dispose?.(); } catch {}
+ }
+}
diff --git a/tools/account-access-test.js b/tools/account-access-test.js
index ad6ce3f..d2685de 100644
--- a/tools/account-access-test.js
+++ b/tools/account-access-test.js
@@ -36,6 +36,8 @@ try {
check('avatar opens Accounts with sign-in and logout', await page.evaluate(() => ['Accounts', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s))));
const saved = await page.evaluate(() => localStorage.getItem('btc-wallet-watch'));
await click('Sign into another account');
+ check('sign-in opens on Create new, not the seed import box', await page.evaluate(() =>
+ document.querySelector('.tabs .active')?.textContent.includes('Create new') && !document.querySelector('.card textarea')));
// Expand the Nostr login card without supplying a private key.
await click('Sign in with Nostr');
await page.waitForSelector('input[placeholder="nsec, private key, or bunker://…"]');
diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js
new file mode 100644
index 0000000..293d7c1
--- /dev/null
+++ b/tools/avatar-profile-test.js
@@ -0,0 +1,124 @@
+// Tapping a face in the feed opens that person's profile AT ITS TOP — not
+// the thread the row would open, and not the profile scrolled down to
+// wherever the reader was in the feed. Back returns to the same place.
+//
+// Run: bun tools/avatar-profile-test.js
+import puppeteer from 'puppeteer-core';
+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: 5271, 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 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:5271/', { 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 A = 'a'.repeat(63) + '9', B = 'b'.repeat(63) + '7';
+ await page.evaluate(([k, a, b]) => {
+ const now = Math.floor(Date.now() / 1000);
+ const ns = Array.from({ length: 30 }, (_, i) => ({
+ id: (i + 16).toString(16).padStart(64, 'c'), pubkey: i % 2 ? b : a, kind: 1, created_at: now - i * 60,
+ content: 'post number ' + i + '\n\n' + 'a few lines so rows have height. '.repeat(4), tags: [],
+ }));
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a], ['p', b]], c: '', at: now }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Alice Poster', t: Date.now() }, [b]: { name: 'Bob Poster', about: 'bob bio here', t: Date.now() } }));
+ }, [base, A, B]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await waitText('post number 15', 15000);
+ await sleep(1500);
+
+ // scroll down to a row well into the feed, then tap its face
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('post number 13')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(700);
+ const feedY = await page.evaluate(() => window.scrollY);
+ const pt = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('post number 13')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
+ await page.touchscreen.tap(pt.x, pt.y);
+ await sleep(1200);
+ const after = await page.evaluate(() => ({ y: window.scrollY, text: document.body.innerText.slice(0, 400), thread: !!document.querySelector('.note-thread, .thread-page') }));
+ if (process.env.DBG) console.log(feedY, JSON.stringify(after));
+ check('the tap opens the profile', /Bob Poster/.test(after.text) && /bob bio here/.test(after.text), after.text.replace(/\s+/g, ' ').slice(0, 80));
+ check('...at its top', after.y < 5, 'scrollY ' + after.y + ' (feed was at ' + feedY + ')');
+
+ await page.goBack().catch(() => {});
+ await sleep(1200);
+ let back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') && document.body.innerText.includes('post number 13') }));
+ if (!back.feed) {
+ await page.evaluate(() => { const b = document.querySelector('.back, [aria-label="Back"]'); if (b) b.click(); });
+ await sleep(1200);
+ back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') }));
+ }
+ check('back returns to the feed where the reader was', back.feed && Math.abs(back.y - feedY) < 60, 'scrollY ' + back.y + ' vs ' + feedY);
+
+ // a post opened into its thread: Back lands where the reader was
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('post number 13')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(700);
+ const y0 = await page.evaluate(() => window.scrollY);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('post number 13')).querySelector('.note-text').click());
+ await sleep(1500);
+ const inThread = await page.evaluate(() => !!document.querySelector('.thread-page'));
+ await page.goBack(); await sleep(1500);
+ const y1 = await page.evaluate(() => window.scrollY);
+ check('back from a post returns to the same place in the feed', inThread && y0 > 500 && Math.abs(y1 - y0) < 60, 'scrollY ' + y1 + ' vs ' + y0);
+
+ // "Their timeline" from the profile: its chip names it (it was a blank pill)
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('Bob Poster')); r.click(); });
+ await sleep(1000);
+ await click('their timeline');
+ await sleep(1500);
+ const chip = await page.evaluate(() => document.querySelector('.feed-chip.on')?.textContent || null);
+ check('someone’s timeline has a labelled chip', !!chip && /Bob Poster/.test(chip), JSON.stringify(chip));
+
+ // ‹ on their timeline goes back to their profile, not the chat list
+ await page.evaluate(() => document.querySelector('.feed-page .chat-back').click());
+ await sleep(1200);
+ const backTo = await page.evaluate(() => ({ profile: /bob bio here/.test(document.body.innerText), chats: /Communities/.test(document.body.innerText) && !document.querySelector('.feed-page') }));
+ check('‹ on someone’s timeline goes back to their profile', backTo.profile && !backTo.chats, JSON.stringify(backTo));
+ // ...and from their timeline, the Feed tab is your own feed again
+ await click('their timeline');
+ await sleep(1500);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await sleep(1200);
+ const mine = await page.evaluate(() => document.querySelector('.feed-chip.on')?.textContent || null);
+ check('the Feed tab brings back your own feed', mine === 'Following', JSON.stringify(mine));
+ // back onto their timeline for the save below
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('Bob Poster')); r.click(); });
+ await sleep(1000);
+ await click('their timeline');
+ await sleep(1500);
+
+ // ⋯ → Save feed: straight into the chips, no editor listing their follows
+ await page.evaluate((l) => document.querySelector('button[aria-label="' + l + '"]').click(), 'Feed options');
+ await sleep(500);
+ const saved = await click('save feed');
+ await sleep(1000);
+ const savedState = await page.evaluate(() => ({ editor: /New feed/.test(document.body.innerText), chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent), on: document.querySelector('.feed-chip.on')?.textContent }));
+ check('“Save feed” on a timeline saves it without the editor', saved && !savedState.editor && /Bob Poster/.test(savedState.on || ''), JSON.stringify(savedState));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await sleep(2000);
+ const kept = await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent));
+ check('...and it is still among the feeds after a reload', kept.some((c) => /(Bob Poster|npub1hwam).*’s timeline/.test(c)), JSON.stringify(kept));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a face opens its profile at the top' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/comments-test.js b/tools/comments-test.js
new file mode 100644
index 0000000..01d47d5
--- /dev/null
+++ b/tools/comments-test.js
@@ -0,0 +1,99 @@
+// NIP-22 comments (kind 1111): a thread rooted in something other than a
+// kind 1 note (here a real long-form article, kind 30023) shows its
+// comments, and a reply to it — or to one of its comments — goes out as a
+// kind 1111 with the root's E/K/P and the parent's e/k/p. Outgoing events
+// are caught at the socket; nothing is published.
+//
+// Run: bun tools/comments-test.js
+import puppeteer from 'puppeteer-core';
+import { SimplePool } from 'nostr-tools/pool';
+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 ROOT = 'b615961519481cffc69af3e6ba9ae45fc236c251f21d327d243804dc94a77026';
+const NEVENT = 'nevent1qvzqqqr4gupzq0mhp4ja8fmy48zuk5p6uy37vtk8tx9dqdwcxm32sy8nsaa8gkeyqqstv9vkz5v5s88lc6d08e46ntj9ls3kcfgly8fj05jrspxujjnhqfsede0ss';
+const pool = new SimplePool();
+const R = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.nostr.band', 'wss://nostr.wine'];
+let root = null, comments = [];
+for (let i = 0; i < 3 && !(root && comments.length); i++) {
+ root ||= (await pool.querySync(R, { ids: [ROOT] }, { maxWait: 8000 }))[0];
+ if (!comments.length) comments = await pool.querySync(R, { kinds: [1111], '#E': [ROOT] }, { maxWait: 5000 });
+}
+if (!root || !comments.length) { console.log(' ✗ the article or its comments are gone from the relays', !!root, comments.length); process.exit(1); }
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5283, 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();
+await page.evaluateOnNewDocument(() => {
+ window.__sent = [];
+ window.__all = [];
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ window.__all.push(this.url + ' ' + String(d).slice(0, 60));
+ if (typeof d === 'string' && d.startsWith('["EVENT"')) { // every event: nothing leaves the test
+ const ev = JSON.parse(d)[1];
+ window.__sent.push(ev);
+ // answer as a relay would, so the app counts it published
+ setTimeout(() => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', ev.id, true, '']) })), 50);
+ return;
+ }
+ return send.call(this, d);
+ };
+});
+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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const reply = async (rowFind, text) => {
+ await page.evaluate((f) => { const row = new Function('return ' + f)()(); row.querySelector('button[aria-label="Reply"]').click(); }, rowFind.toString());
+ await waitFor(() => !!document.querySelector('.thread-reply-input'), 5000);
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT + text.length + '.png' });
+ await page.focus('.thread-reply-input');
+ await page.keyboard.type(text);
+ if (process.env.DBG) console.log('typed:', await page.evaluate(() => ({ boxes: document.querySelectorAll('.thread-reply-input').length, v: document.querySelector('.thread-reply-input')?.value, sendDisabled: document.querySelector('.thread-reply-send')?.disabled, active: document.activeElement?.className })));
+ await page.evaluate(() => document.querySelector('.thread-reply-send').click());
+ await sleep(300);
+ if (process.env.DBG && text.startsWith('a reply')) console.log('ALL', await page.evaluate(() => window.__all.slice(-25)));
+ if (process.env.DBG) console.log('after send:', await page.evaluate(() => ({ boxes: document.querySelectorAll('.thread-reply-input').length, toast: document.querySelector('.toast.show')?.textContent })));
+ // one event per relay it goes to: pick the one carrying this text
+ const mine = 'return window.__sent.find((e) => e.content.endsWith(' + JSON.stringify(text) + '))';
+ await waitFor(new Function('return !!(() => { ' + mine + ' })()'), 20000);
+ return page.evaluate(new Function(mine));
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5283/', { 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.goto('http://localhost:5283/' + NEVENT, { waitUntil: 'domcontentloaded' });
+ const first = comments.sort((a, b) => a.created_at - b.created_at)[0];
+ const shown = await waitFor(new Function('return document.querySelector(".thread-page")?.innerText.includes(' + JSON.stringify(first.content.slice(0, 20)) + ')'), 25000);
+ check('the article opens with its NIP-22 comments', shown, JSON.stringify(first.content.slice(0, 40)));
+ const rows = await page.evaluate(() => document.querySelectorAll('.thread-page [data-zap-post]').length);
+ check('...each as a row of the thread', rows >= 1 + Math.min(comments.length, 2), rows + ' rows for ' + comments.length + ' comments');
+
+ const ev = await reply(() => document.querySelector('.thread-page [data-zap-post]'), 'a comment on the article');
+ const tag = (e, n) => (e?.tags || []).filter((x) => x[0] === n).map((x) => x[1]);
+ check('a reply to the article is a kind 1111 comment', ev?.kind === 1111, 'kind ' + ev?.kind);
+ check('...with the root as E/K/P', tag(ev, 'E')[0] === ROOT && tag(ev, 'K')[0] === '30023' && tag(ev, 'P')[0] === root.pubkey, JSON.stringify(ev?.tags));
+ check('...and its address as A', tag(ev, 'A')[0] === '30023:' + root.pubkey + ':' + tag(root, 'd')[0]);
+ check('...and the article as the parent (e/k/p)', tag(ev, 'e')[0] === ROOT && tag(ev, 'k')[0] === '30023' && tag(ev, 'p')[0] === root.pubkey);
+
+ await page.evaluate(() => document.querySelector('.thread-reply-cancel')?.click());
+ const onScreen = await page.evaluate(() => [...document.querySelectorAll('.thread-page [data-zap-post]')].filter((r) => r.querySelector('button[aria-label="Reply"]')).map((r) => r.getAttribute('data-zap-post')));
+ const target = comments.find((c) => onScreen.includes(c.id));
+ if (process.env.DBG) console.log('rows with Reply', onScreen.length, 'comments', comments.map((c) => c.id.slice(0, 8)), 'shown', onScreen.map((x) => x.slice(0, 8)));
+ const cid = target?.id;
+ const ev2 = await reply(new Function('return () => document.querySelector(\'.thread-page [data-zap-post="' + cid + '"]\')')(), 'a reply to a comment');
+ if (process.env.DBG) console.log(await page.evaluate((cid) => ({ sent: window.__sent.map((e) => e.kind + ':' + e.content.slice(0, 30)), row: !!document.querySelector('.thread-page [data-zap-post="' + cid + '"]'), box: document.querySelector('.thread-reply-input')?.value, toast: document.querySelector('.toast')?.textContent, sending: !!document.querySelector('.thread-reply-send .spinner') }), cid));
+ if (process.env.DBG) console.log('EV2', JSON.stringify(ev2)?.slice(0, 200), await page.evaluate(() => window.__sent.filter((e) => e.kind === 1111).map((e) => JSON.stringify(e.content))));
+ check('a reply to a comment is a kind 1111 too', ev2?.kind === 1111 && tag(ev2, 'E')[0] === ROOT, 'kind ' + ev2?.kind);
+ check('...its parent the comment (e/k/p)', tag(ev2, 'e')[0] === cid && tag(ev2, 'k')[0] === '1111' && tag(ev2, 'p')[0] === target?.pubkey, JSON.stringify(ev2?.tags.filter((x) => /^[ekp]$/.test(x[0]))));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ comments read and write as NIP-22' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/compose-preview-test.js b/tools/compose-preview-test.js
index c2f35e6..2138ea6 100644
--- a/tools/compose-preview-test.js
+++ b/tools/compose-preview-test.js
@@ -66,9 +66,9 @@ try {
await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
await sleep(2500);
const opened = await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((n) => (n.getAttribute('aria-label') || n.textContent).trim().toLowerCase() === 'new post'); if (b) { b.click(); return true; } return false; });
- check('the composer opens', opened && !!(await page.$('textarea')));
+ check('the composer opens', opened && !!(await page.$('coinos-text')));
- await type('textarea', DRAFT);
+ await type('coinos-text', DRAFT);
await sleep(400);
check('no preview until it is asked for', !(await page.$('.draft-preview')));
@@ -92,7 +92,7 @@ try {
shown ? JSON.stringify(shown.text.slice(0, 60)) : '');
// live: keep typing and the preview follows
- await type('textarea', DRAFT + ' plus a word');
+ await type('coinos-text', DRAFT + ' plus a word');
await sleep(900);
check('it follows the draft as you type',
/plus a word/.test(await page.evaluate(() => (document.querySelector('.draft-preview') || {}).innerText || '')));
diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js
new file mode 100644
index 0000000..9b2b41c
--- /dev/null
+++ b/tools/composer-media-test.js
@@ -0,0 +1,103 @@
+// The reply box is <coinos-text>, a contenteditable field — the kind
+// Android keyboards offer GIFs and images to (a <textarea> gets the GIF key
+// greyed out). It must still type like a textarea, keep pasted text plain,
+// and send a pasted/keyboard picture through the same upload as the
+// paperclip, its URL landing in the draft.
+//
+// Run: bun tools/composer-media-test.js
+import puppeteer from 'puppeteer-core';
+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: 5279, 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 uploads = [];
+await page.setRequestInterception(true);
+page.on('request', (req) => {
+ const u = req.url();
+ if (/nostr\.download|nostr\.build/.test(u) && req.method() === 'PUT') {
+ uploads.push(u);
+ return req.respond({ status: 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' },
+ body: JSON.stringify({ url: 'https://nostr.download/' + 'f'.repeat(64) + '.gif', sha256: 'f'.repeat(64), size: 10, type: 'image/gif' }) });
+ }
+ if (/nostr\.download|nostr\.build/.test(u) && req.method() === 'OPTIONS') {
+ return req.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' } });
+ }
+ req.continue();
+});
+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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5279/', { 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 A = 'a'.repeat(63) + '9';
+ await page.evaluate(([k, a]) => {
+ const now = Math.floor(Date.now() / 1000);
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: now }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify([{ id: 'c'.repeat(64), pubkey: a, kind: 1, created_at: now, content: 'a post to answer', tags: [] }]));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Poster', t: Date.now() } }));
+ }, [base, A]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await waitText('a post to answer', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('a post to answer')).querySelector('button[aria-label="Reply"]').click());
+ check('the reply box appears', await waitFor(() => !!document.querySelector('.thread-reply-input'), 10000));
+
+ const box = await page.evaluate(() => { const b = document.querySelector('.thread-reply-input'); return { tag: b.tagName, ce: b.getAttribute('contenteditable'), empty: b.hasAttribute('data-empty') }; });
+ check('it is the contenteditable field keyboards offer pictures to', box.tag === 'COINOS-TEXT' && box.ce === 'true', JSON.stringify(box));
+ check('...showing its placeholder while empty', box.empty);
+
+ await page.focus('.thread-reply-input');
+ await page.keyboard.type('hello');
+ await page.keyboard.down('Shift'); await page.keyboard.press('Enter'); await page.keyboard.up('Shift');
+ await page.keyboard.type('world');
+ let v = await page.evaluate(() => document.querySelector('.thread-reply-input').value);
+ check('it types like a textarea, Shift+Enter a newline', v === 'hello\nworld', JSON.stringify(v));
+
+ // pasted rich text arrives as plain text
+ await page.evaluate(() => {
+ const dt = new DataTransfer();
+ dt.setData('text/html', '<b style="color:red">bold</b> <a href="x">link</a>');
+ dt.setData('text/plain', ' bold link');
+ document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
+ });
+ v = await page.evaluate(() => ({ v: document.querySelector('.thread-reply-input').value, html: document.querySelector('.thread-reply-input').innerHTML }));
+ check('pasted formatting is dropped, the text kept', v.v === 'hello\nworld bold link' && !/<b|<a /.test(v.html), JSON.stringify(v));
+
+ // a picture from the keyboard/clipboard goes up like the paperclip's
+ await page.evaluate(() => {
+ const dt = new DataTransfer();
+ const gif = Uint8Array.from(atob('R0lGODlhAQABAAAAACw='), (c) => c.charCodeAt(0));
+ dt.items.add(new File([gif], 'wave.gif', { type: 'image/gif' }));
+ 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,
+ 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
+ await page.focus('.thread-reply-input');
+ await page.evaluate(() => { const x = document.querySelector('.thread-reply-input'); x.setSelectionRange(x.value.length); });
+ await page.keyboard.type(' ok');
+ const after = await page.evaluate(() => ({ v: document.querySelector('.thread-reply-input').value, ce: document.querySelector('.thread-reply-input').getAttribute('contenteditable') }));
+ check('after a repaint it still takes typing', after.ce === 'true' && after.v.endsWith('.gif ok'), JSON.stringify(after).slice(-60));
+ const noImg = await page.evaluate(() => !document.querySelector('.thread-reply-input img'));
+ check('no image element is left inside the text', noImg);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the reply box takes pictures' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js
new file mode 100644
index 0000000..4354e97
--- /dev/null
+++ b/tools/feed-bottom-nav-test.js
@@ -0,0 +1,99 @@
+// Real feed/navigation views with local posts and no relay traffic.
+// Run: bun tools/app-bottom-nav-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');"));
+const entry = `
+import { messagesFeature } from './src/features/messages.js';
+${dom}
+const author = 'a'.repeat(64);
+const ui = { screen: 'wallet', chatOpen: true, msgView: 'feed' };
+const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [],
+ loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d,
+ saveFeatureState() {}, registerCacheExtension() {} };
+let feature;
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); }
+feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') });
+window.test = { ui, wallet, render, get feature() { return feature; }, async feed() {
+ ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null;
+ await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i,
+ content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) })));
+ render(); window.scrollTo(0,0);
+}, unseen() { feature.testUnseen(); render(); } };
+await test.feed();
+`;
+const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) {
+ build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'}));
+ build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()}));
+ build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; }, testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")}));
+ build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text())
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];')
+ .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')}));
+} }] });
+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 remote = !!process.env.BROWSER_URL;
+const server=Bun.serve({port:remote ? 5303 : 0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})});
+const browser=remote ? await puppeteer.connect({browserURL:process.env.BROWSER_URL}) : 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 scroll=async()=>{ await page.evaluate(()=>window.scrollTo(0,700)); await page.waitForFunction(()=>scrollY > 0 && document.querySelector('.app-bottom-nav')?.getBoundingClientRect().height > 0); };
+try {
+ await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true});
+ await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]);
+ await page.goto(server.url.href);
+ await page.waitForSelector('.note-post', {timeout:5000});
+ assert(await page.$eval('.app-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'visible at top without scrolling');
+ await scroll();
+ assert.deepEqual(await page.$$eval('.app-nav-button',els=>els.map(e=>e.textContent)),['Feed','Messages','Notifications','Wallet']);
+ assert(await page.$eval('.app-bottom-nav',e=>Math.abs(e.getBoundingClientRect().bottom-innerHeight)<1),'fixed at viewport bottom');
+ await page.evaluate(()=>test.unseen());
+ assert(await page.evaluate(()=>document.querySelector('.feed-new-pill').getBoundingClientRect().bottom < document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'new posts pill stays above navigation');
+ await page.screenshot({path:'/tmp/app-bottom-nav-mobile.png'});
+ await page.tap('.app-nav-button:nth-child(1)');
+ await page.waitForFunction(()=>scrollY===0 && !document.querySelector('.app-bottom-nav').hidden);
+ assert.equal(await page.evaluate(()=>test.ui.msgView),'feed','Feed stays in feed');
+ await scroll(); await page.tap('.app-nav-button:nth-child(2)');
+ await page.waitForFunction(()=>test.ui.msgView==='home' && !!document.querySelector('.app-bottom-nav'));
+ assert((await page.evaluate(()=>document.body.innerText)).includes('Messages'),'chat list renders');
+ assert(await page.$eval('.app-nav-button:nth-child(2)',e=>e.getAttribute('aria-current')==='page'),'messages marked active');
+ const rows=()=>page.evaluate(()=>{const ls=[...document.querySelectorAll('.chat-page > .list')];return ls.map(l=>l.querySelectorAll('.chat-thread-row').length);});
+ const links=()=>page.$$eval('.chat-page > button.linklike',els=>els.map(e=>e.textContent));
+ await page.evaluate(()=>{const m={};const orig=test.wallet.loadFeatureState;test.wallet.loadFeatureState=(k,d)=>k==='messages'?m:orig(k,d);test.feature.testChats(4,4);test.render();});
+ await page.waitForFunction(()=>document.querySelectorAll('.chat-page .chat-thread-row').length>=8);
+ assert.deepEqual(await rows(),[4,4]); assert.deepEqual(await links(),['Show all 5 communities'],'four DMs show whole; five communities are gated');
+ await page.evaluate(()=>{test.feature.testChats(9);test.render();});
+ assert.deepEqual(await rows(),[4,4]); assert.deepEqual(await links(),['Show all 9 conversations','Show all 5 communities']);
+ await page.$$eval('.chat-page > button.linklike',els=>els.forEach(e=>e.click()));
+ assert.deepEqual(await rows(),[9,5]); assert.deepEqual(await links(),['Show fewer','Show fewer']);
+ await page.$$eval('.chat-page > button.linklike',els=>els.forEach(e=>e.click()));
+ assert.deepEqual(await rows(),[4,4],'show fewer folds both lists back');
+ await page.evaluate(()=>{test.ui.msgView='dm';test.ui.msgPeer='b'.repeat(64);test.render();});
+ await page.waitForSelector('.chat-card');
+ assert(await page.evaluate(()=>document.querySelector('.chat-card').getBoundingClientRect().bottom <= document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'conversation and composer fit above bottom nav');
+ await page.screenshot({path:'/tmp/app-nav-chat.png'});
+ await page.tap('.app-nav-button:nth-child(3)');
+ await page.waitForFunction(()=>test.ui.msgView==='notifs' && !!document.querySelector('.app-bottom-nav'));
+ assert((await page.evaluate(()=>document.body.innerText)).includes('Notifications'),'notifications render');
+ await page.tap('.app-nav-button:nth-child(1)'); await scroll(); await page.tap('.note-text');
+ await page.waitForFunction(()=>!!test.ui.noteThread && !!document.querySelector('.app-bottom-nav'));
+ await page.tap('.app-nav-button:nth-child(1)');
+ assert(await page.$eval('.app-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'returning to feed restores nav immediately without a scroll');
+ await page.evaluate(()=>{test.ui.tab='settings';});
+ await page.tap('.app-nav-button:nth-child(4)');
+ await page.waitForSelector('.wallet-screen');
+ assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.tab==='receive' && !!document.querySelector('.app-bottom-nav')),'Wallet opens main wallet instead of old settings tab');
+ await page.tap('.app-nav-button:nth-child(1)');
+ await page.waitForSelector('.feed-page');
+ await page.setViewport({width:320,height:844,isMobile:true,hasTouch:true});
+ assert(await page.$eval('.app-bottom-nav',e=>e.scrollWidth<=e.clientWidth),'four buttons fit a narrow phone');
+ await page.screenshot({path:'/tmp/app-bottom-nav-always-mobile.png'});
+ await page.evaluate(()=>{test.wallet.xpub=null;test.render();}); await scroll();
+ await page.tap('.app-nav-button:nth-child(2)');
+ assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.msgView===null),'visitor reaches sign-in screen');
+ assert.deepEqual(errors,[]);
+ console.log('✓ Global nav works across feed, chat list, conversations, notifications, threads and wallet; Feed restores the feed; composer remains above nav; visitor entry and narrow layout work; DMs and communities cap at four behind Show all');
+} finally { await page.close(); if (remote) await browser.disconnect(); else await browser.close(); server.stop(true); }
diff --git a/tools/feed-cache-test.js b/tools/feed-cache-test.js
index 995e01a..3df9447 100644
--- a/tools/feed-cache-test.js
+++ b/tools/feed-cache-test.js
@@ -12,14 +12,28 @@ storage.setItem('wallet:funds', 'untouched');
assert.equal(cache.read('following').length, 30);
assert.equal(JSON.parse(storage.getItem('wallet:feedNotes')).notes.length, 30);
for (let i = 0; i < 5; i++) cache.save('custom' + i, notes);
-assert.equal(cache.read('following').length, 0);
-assert.equal([...values.keys()].filter((k) => k.includes('feedNotes')).length, 4);
-cache.read('custom1');
-cache.save('custom5', notes);
-assert.equal(cache.read('custom2').length, 0);
+const stored = (id) => JSON.parse(storage.getItem(id === 'following' ? 'wallet:feedNotes' : 'wallet:feedNotes:' + id) || 'null');
+assert.equal(stored('following').notes.length, 5, 'a feed past the four most recent keeps seed posts');
+assert.equal(stored('custom4').notes.length, 30);
+assert.equal(cache.read('following').length, 5, 'and reads them back');
+for (let i = 5; i < 14; i++) cache.save('custom' + i, notes);
+assert.equal([...values.keys()].filter((k) => k.includes('feedNotes')).length, 12);
+assert.equal(stored('custom0'), null, 'the least recently read goes first');
+assert.equal(stored('custom3').notes.length, 5);
+cache.read('custom3'); cache.save('custom3', notes);
+assert.equal(stored('custom3').notes.length, 30, 'reopened, it gets a full page again');
+assert.equal(stored('custom10').notes.length, 5, 'and the oldest of the four drops to seed posts');
+// seeds warmed ahead never outrank or overwrite a feed that was read
+cache.seed('custom13', notes.slice(0, 2));
+assert.equal(stored('custom13').notes.length, 30);
+assert.equal(cache.has('warm'), false);
+cache.seed('warm', notes);
+assert.equal(cache.has('warm'), true);
+assert.equal(stored('warm').notes.length, 5);
+assert.equal(stored('custom13').notes.length, 30);
cache.save('big', notes.map((e) => ({ ...e, content: 'x'.repeat(15000) })));
assert([...values].filter(([k]) => k.includes('feedNotes')).reduce((n, [, v]) => n + v.length, 0) <= 256000);
time += 8 * 86400_000;
assert.equal(cache.read('big').length, 0);
assert.equal(storage.getItem('wallet:funds'), 'untouched');
-console.log('✓ legacy caches shrink; 30 posts/feed, four feeds, byte budget, LRU and seven-day expiry enforced');
+console.log('✓ legacy caches shrink; 30 posts for the four most recent feeds, 5 seed posts for up to 12, byte budget, LRU and seven-day expiry enforced');
diff --git a/tools/feed-nav-destination-test.js b/tools/feed-nav-destination-test.js
new file mode 100644
index 0000000..b14a124
--- /dev/null
+++ b/tools/feed-nav-destination-test.js
@@ -0,0 +1,18 @@
+// The Feed destination and the new-posts pill deliberately have different
+// navigation semantics. Keep this small source-level guard beside the fuller
+// browser test for the pill's landing position.
+import assert from 'node:assert/strict';
+
+const source = await Bun.file('src/features/messages.js').text();
+const navStart = source.indexOf("button('feed', t('feedTitle')");
+const navEnd = source.indexOf("button('messages',", navStart);
+const feedButton = source.slice(navStart, navEnd);
+assert(navStart > 0 && navEnd > navStart, 'Feed bottom-nav handler exists');
+assert(feedButton.includes('glideToTop()'), 'Feed bottom-nav button returns to the top');
+assert(!feedButton.includes('jumpToNew()'), 'Feed bottom-nav button does not jump to unread posts');
+
+const pillStart = source.indexOf("class: 'feed-new-pill'");
+const pill = source.slice(pillStart, source.indexOf('})', pillStart) + 2);
+assert(pillStart > 0 && pill.includes('jumpToNew()'), 'new-posts pill jumps to the first unread arrival');
+
+console.log('✓ Feed tab goes to the top; only the new-posts pill jumps to unread posts');
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
new file mode 100644
index 0000000..c90f0d5
--- /dev/null
+++ b/tools/feed-new-posts-test.js
@@ -0,0 +1,74 @@
+// New posts arriving mid-read: the pill lands on the OLDEST new post, and
+// each new post keeps a tint until it has been in view a couple of seconds.
+// Run: bun tools/feed-new-posts-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');"));
+const entry = `
+import { messagesFeature } from './src/features/messages.js';
+${dom}
+const author = 'a'.repeat(64);
+const ui = { screen: 'wallet', chatOpen: true, msgView: 'feed' };
+const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [],
+ loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d,
+ saveFeatureState() {}, registerCacheExtension() {} };
+let feature;
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); }
+feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') });
+window.test = { ui, wallet, render, get feature() { return feature; }, async feed() {
+ ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null;
+ await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i,
+ content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) })));
+ render(); window.scrollTo(0,0);
+}, unseen() { feature.testUnseen(); render(); }, arrive(n) { const now = Math.floor(Date.now()/1000); feature.testArrive(Array.from({length:n}, (_, i) => ({ id: 'f' + i.toString(16).padStart(63,'0'), pubkey:author, kind:1, tags:[], created_at: now + 100 - i, content: 'Brand new post number ' + i + '. '.repeat(1) + 'Fresh words. '.repeat(20) }))); } };
+await test.feed();
+`;
+const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) {
+ build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'}));
+ build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()}));
+ build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; }, testArrive: (notes) => admitFeed(notes, feed), testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")}));
+ build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text())
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];')
+ .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')}));
+} }] });
+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:()=>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 sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
+const top=(id)=>page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');return r?Math.round(r.getBoundingClientRect().top):null;},id);
+const fresh=()=>page.$$eval('.notes-feed > .row.note-fresh',els=>els.map(e=>e.getAttribute('data-key').slice(0,2)+e.getAttribute('data-key').slice(-1)));
+const F=(i)=>'f'+i.toString(16).padStart(63,'0');
+try {
+ await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true});
+ await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]);
+ await page.goto(server.url.href);
+ await page.waitForSelector('.note-post',{timeout:5000});
+ const reading='6'.padStart(64,'0');
+ 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);
+ const before=await top(reading);
+ await page.evaluate(()=>test.arrive(3)); await sleep(300);
+ assert(Math.abs((await top(reading))-before)<=2,'the row being read stays put when posts arrive above');
+ assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'3');
+ assert.deepEqual((await fresh()).length,3,'all three new posts wear the tint');
+ await page.tap('.feed-new-pill'); await sleep(400);
+ const oldest=await top(F(2));
+ assert(oldest>=0&&oldest<=20,'pill lands on the oldest new post, not the top: '+oldest);
+ assert(await page.evaluate(()=>scrollY)>0,'not scrolled all the way to the top');
+ assert(!(await page.$('.feed-new-pill')),'pill is gone after the tap');
+ await page.screenshot({path:'/tmp/feed-new-posts.png'});
+ await sleep(2600);
+ assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(2)+'"]')),'the post you looked at loses its tint');
+ assert(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]'),'the newest, still offscreen above, keeps its tint');
+ await page.evaluate(()=>window.scrollTo(0,0)); await sleep(2600);
+ assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen');
+ assert.deepEqual(errors,[]);
+ console.log('✓ New posts: pill lands on the oldest new post; tints stay until each post has been in view ~2s; the reading row stays put');
+} finally { await page.close(); await browser.close(); server.stop(true); }
diff --git a/tools/feed-switch-seed-test.js b/tools/feed-switch-seed-test.js
new file mode 100644
index 0000000..a012dfe
--- /dev/null
+++ b/tools/feed-switch-seed-test.js
@@ -0,0 +1,125 @@
+// Switching to a feed paints posts at once, never a bare spinner, even when
+// the relays are slow: a feed that was never opened borrows matching posts
+// other open feeds already hold, and once the feed on screen settles the
+// other feeds' first posts are fetched ahead into the disk cache.
+// Run: bun tools/feed-switch-seed-test.js
+import puppeteer from 'puppeteer-core';
+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 A = generateSecretKey(), B = generateSecretKey();
+const PK_A = getPublicKey(A), PK_B = getPublicKey(B);
+const now = Math.floor(Date.now() / 1000);
+const post = (sk, i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, sk);
+// Alice is followed; one of her posts is on #bitcoin. Bob is only in his own feed.
+const alice = Array.from({ length: 12 }, (_, i) => post(A, i, 'alice post ' + (i + 1), i === 3 ? [['t', 'bitcoin']] : []));
+const bob = Array.from({ length: 12 }, (_, i) => post(B, i, 'bob post ' + (i + 1)));
+
+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 browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// Stub relay: kind-1 requests answered from the posts above, by author and
+// (for topics) never — until window.__slow, when every answer takes 8 s.
+await page.evaluateOnNewDocument((all) => {
+ const Real = window.WebSocket;
+ window.__reqs = [];
+ 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(1) && !f['#e'] && !f.ids && !f['#t'] && f.authors
+ ? all.filter((e) => f.authors.includes(e.pubkey) && (!f.until || e.created_at <= f.until)).slice(0, f.limit || 100) : [];
+ if (f.authors) window.__reqs.push({ authors: f.authors, limit: f.limit, slow: !!window.__slow });
+ setTimeout(() => {
+ for (const e of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, window.__slow ? 8000 : 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, [...alice, ...bob]);
+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 chip = (name) => page.evaluate((n) => { const e = [...document.querySelectorAll('.feed-chip')].find((b) => b.textContent.trim() === n); if (e) e.click(); return !!e; }, name);
+const rows = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.innerText.match(/(alice|bob) post \d+/)?.[0]).filter(Boolean));
+const spinner = () => page.evaluate(() => !document.querySelector('.notes-feed') && !!document.querySelector('.spinner'));
+
+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, a, b, ns]) => {
+ const t = Date.now();
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: Math.floor(t / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Alice', t }, [b]: { name: 'Bob', t } }));
+ const s = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
+ s.feeds = [{ id: 'bob', name: 'Bobfeed', follows: false, authors: [b], topics: [], packs: [] },
+ { id: 'btc', name: 'Btcfeed', follows: false, authors: [], topics: ['bitcoin'], packs: [] }];
+ localStorage.setItem(k + ':messages', JSON.stringify(s));
+ localStorage.setItem('btc-wallet-feed', 'following');
+ }, [base, PK_A, PK_B, alice]);
+ 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', { timeout: 15000 });
+ check('Following opens on its cached posts', (await rows())[0] === 'alice post 1', JSON.stringify((await rows()).slice(0, 2)));
+
+ // the feed on screen settles, then Bob's feed is fetched ahead
+ let seeded = null;
+ for (let i = 0; i < 40 && !seeded; i++) {
+ await sleep(250);
+ seeded = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':feedNotes:bob') || 'null'), base);
+ }
+ check('another feed is fetched ahead into the disk cache', seeded?.seed && seeded.notes.length > 0 && seeded.notes.length <= 5,
+ JSON.stringify(seeded && { seed: seeded.seed, n: seeded.notes.length }));
+ check('...with a small request', (await page.evaluate(() => window.__reqs)).some((r) => r.authors.join() === PK_B && r.limit <= 10));
+
+ // from here every relay answer takes 8 s
+ await page.evaluate(() => { window.__slow = true; });
+ check('the Bob chip is there', await chip('Bobfeed'));
+ await sleep(400);
+ const bobRows = await rows();
+ check('switching to a feed fetched ahead paints its posts at once', bobRows[0] === 'bob post 1', JSON.stringify(bobRows.slice(0, 3)));
+ check('...not a spinner', !(await spinner()));
+
+ await chip('Btcfeed');
+ await sleep(400);
+ const btcRows = await rows();
+ check('a topic feed never opened borrows a matching post an open feed holds', btcRows.includes('alice post 4') && btcRows.length === 1, JSON.stringify(btcRows));
+ check('...not a spinner', !(await spinner()));
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ console.log('✗ threw', e.message); ok = false;
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ feed switching paints at once' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js
new file mode 100644
index 0000000..214b977
--- /dev/null
+++ b/tools/feed-video-aspect-test.js
@@ -0,0 +1,117 @@
+// A video in the feed paints at its own shape, not a 16:9 frame with black
+// bands either side: from the post's imeta `dim` when it says, else from the
+// clip's metadata fetched before the row is admitted. A clip with no known
+// shape still gets the 16:9 box.
+// Run: bun tools/feed-video-aspect-test.js (CHROME=<path> to pick a browser)
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { mkdtempSync } from 'fs';
+import { tmpdir } from '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() + '/vid-');
+const clip = (name, size) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `testsrc=size=${size}:rate=10`, '-t', '1',
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${name}`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+};
+clip('tall.mp4', '360x480'); clip('wide.mp4', '640x360'); clip('told.mp4', '360x640');
+
+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;
+ // this one's metadata arrives too late for a probe: only the imeta dim can shape it
+ if (path === '/v/told.mp4') await sleep(6000);
+ if (path.startsWith('/v/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'video/mp4' } });
+ if (path === '/broken.mp4') return new Response('nope', { status: 404 });
+ 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 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, 'tall clip ' + base + '/v/tall.mp4'),
+ post(1, 'wide clip ' + base + '/v/wide.mp4'),
+ // imeta says 9:16; the file is too, but the probe must not be needed
+ post(2, 'told clip ' + base + '/v/told.mp4', [['imeta', 'url ' + base + '/v/told.mp4', 'm video/mp4', 'dim 1080x1920']]),
+ post(3, 'broken clip ' + base + '/broken.mp4'),
+];
+
+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();
+const videoReqs = [];
+page.on('request', (r) => { if (/\/v\/told\.mp4/.test(r.url())) videoReqs.push(Date.now()); });
+await page.evaluateOnNewDocument(() => {
+ // no autoplay: a clip that played keeps its first box, and this test is
+ // about the box the settled feed row paints
+ HTMLMediaElement.prototype.play = function () { return Promise.reject(new Error('no autoplay in this test')); };
+ 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') { setTimeout(() => 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);
+});
+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; };
+// each clip's box, as painted in the feed (before any metadata of its own)
+const boxes = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.notes-feed video.note-video')].map((v) => {
+ const r = v.getBoundingClientRect();
+ return [(v.getAttribute('src') || v.getAttribute('data-lazy-src') || '').split('/').pop(), { w: Math.round(r.width), h: Math.round(r.height), style: v.getAttribute('style') }];
+})));
+
+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: 'Clipper', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed', 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ videoReqs.length = 0;
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting]) video.note-video', { timeout: 20000 });
+ const b = await boxes();
+ const ratio = (x) => x && x.h ? +(x.w / x.h).toFixed(2) : 0;
+ check('a 3:4 clip, measured ahead, paints at 3:4 full width', ratio(b['tall.mp4']) === 0.75 && b['tall.mp4'].w > 300 && /aspect-ratio:360\/480/.test(b['tall.mp4'].style || ''), JSON.stringify(b['tall.mp4']));
+ check('a 16:9 clip stays 16:9', Math.abs(ratio(b['wide.mp4']) - 1.78) < 0.02 && /aspect-ratio:640\/360/.test(b['wide.mp4'].style || ''), JSON.stringify(b['wide.mp4']));
+ check('a clip whose post gives imeta dim paints at that shape, without waiting on the file', Math.abs(ratio(b['told.mp4']) - 0.56) < 0.02 && /aspect-ratio:1080\/1920/.test(b['told.mp4'].style || ''), JSON.stringify(b['told.mp4']));
+ check('...capped at three quarters of the screen height', b['told.mp4'] && b['told.mp4'].h <= Math.ceil(844 * 0.75) + 1, JSON.stringify(b['told.mp4']));
+ check('a clip that will not load keeps the 16:9 box', /aspect-ratio:16\/9/.test(b['broken.mp4']?.style || ''), JSON.stringify(b['broken.mp4']));
+ await sleep(1500);
+ check('shapes are remembered on the device', await page.evaluate(() => (JSON.parse(localStorage.getItem('coinos-video-dims') || '[]')).length >= 3));
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ console.log('✗ threw', e.stack); ok = false;
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ feed videos paint at their own shape' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-wallet-back-test.js b/tools/feed-wallet-back-test.js
new file mode 100644
index 0000000..1b0abe7
--- /dev/null
+++ b/tools/feed-wallet-back-test.js
@@ -0,0 +1,25 @@
+// Exercise the real app history: a public feed must survive Wallet -> Back.
+// Run: bun tools/feed-wallet-back-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+import { buildHtml } from '../build.js';
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: req => new URL(req.url).pathname === '/api/feed'
+ ? new Response(null, { status: 404 }) : new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.setRequestInterception(true);
+ page.on('request', r => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
+ await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.app-bottom-nav', { visible: true });
+ await page.tap('.app-nav-button:nth-child(4)');
+ await page.waitForFunction(() => !document.querySelector('.app-bottom-nav'));
+ assert(!(await page.$('.app-bottom-nav')), 'no nav over the welcome screen');
+ await page.goBack({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.app-bottom-nav', { visible: true, timeout: 5000 });
+ assert(await page.$eval('.app-bottom-nav', e => !e.hidden && e.getBoundingClientRect().height > 0));
+ assert.equal(await page.evaluate(() => scrollY), 0, 'visible on return without scrolling');
+ console.log('✓ Real browser Back restores the public feed and visible navigation after Wallet');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/image-tap-test.js b/tools/image-tap-test.js
new file mode 100644
index 0000000..c531bba
--- /dev/null
+++ b/tools/image-tap-test.js
@@ -0,0 +1,70 @@
+// A tall picture in a feed post is fitted inside its box, leaving blank
+// bands either side. A tap on a band opens the post (its thread); a tap on
+// the picture opens the picture.
+//
+// Run: bun tools/image-tap-test.js
+import puppeteer from 'puppeteer-core';
+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 png = '/tmp/coinos-image-tap-test.png';
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=800x1600', '-frames:v', '1', png]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5281, fetch: (req) => new URL(req.url).pathname === '/tall.png'
+ ? new Response(Bun.file(png), { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } })
+ : 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 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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5281/', { 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 A = 'a'.repeat(63) + '9';
+ await page.evaluate(([k, a]) => {
+ const now = Math.floor(Date.now() / 1000);
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: now }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify([{ id: 'c'.repeat(64), pubkey: a, kind: 1, created_at: now, content: 'a tall one http://localhost:5281/tall.png', tags: [] }]));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Poster', t: Date.now() } }));
+ }, [base, A]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ check('the picture is in the post', await waitFor(() => { const i = document.querySelector('.notes-feed img.note-img'); return i && i.complete && i.naturalWidth > 0; }, 20000));
+ // on the phone the box spans the column and the picture sits in its middle
+ // (object-fit: contain); desktop Chrome sizes the box to the picture, so
+ // widen it the way the phone lays it out
+ const widen = () => page.evaluate(() => { const i = document.querySelector('.notes-feed img.note-img'); i.style.width = '100%'; i.style.height = '320px'; });
+ await widen();
+ const r = await page.evaluate(() => { const b = document.querySelector('.notes-feed img.note-img').getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height }; });
+ const drawn = r.h * 800 / 1600; // the picture's drawn width inside the box
+ check('the box is wider than the picture (blank bands)', r.w > drawn + 40, JSON.stringify({ box: Math.round(r.w), drawn: Math.round(drawn) }));
+
+ const beside = process.env.DBG && await page.evaluate(([x, y]) => { const e = document.elementFromPoint(x, y); const chain = []; for (let n = e; n && chain.length < 5; n = n.parentElement) chain.push(n.tagName + '.' + (n.className || '').toString().split(' ').join('.') + (n.onclick ? '[click]' : '')); return chain; }, [r.x + 8, r.y + r.h / 2]);
+ await page.mouse.click(r.x + 8, r.y + r.h / 2);
+ await sleep(1200);
+ const band = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox') }));
+ check('a tap on the blank band opens the post', band.thread && !band.lightbox, JSON.stringify(band));
+ await page.goBack(); await sleep(1200);
+
+ await widen();
+ const r2 = await page.evaluate(() => { const b = document.querySelector('.notes-feed img.note-img').getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height }; });
+ await page.mouse.click(r2.x + r2.w / 2, r2.y + r2.h / 2);
+ await sleep(800);
+ const pic = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox') }));
+ check('a tap on the picture opens the picture', pic.lightbox && !pic.thread, JSON.stringify(pic));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ bands open the post, the picture opens the picture' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js
new file mode 100644
index 0000000..3be5798
--- /dev/null
+++ b/tools/image-zoom-test.js
@@ -0,0 +1,84 @@
+// The full-screen picture: pinch to zoom, pan when zoomed, double-tap to zoom
+// and reset, a background render keeps the zoom, taps after a gesture don't
+// close it, a plain tap still does, and the wheel zooms on a desktop.
+// Run: bun tools/image-zoom-test.js
+import assert from 'node:assert/strict';
+import puppeteer from 'puppeteer-core';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');"));
+const viewer = app.slice(app.indexOf('function imageViewer()'), app.indexOf("if (typeof window !== 'undefined') {\n window.addEventListener('keydown'"));
+const svg = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><rect width="800" height="600" fill="#48a"/><circle cx="400" cy="300" r="120" fill="#fc3"/></svg>');
+const entry = `
+${dom}
+${viewer}
+const ui = { lightbox: 'data:image/svg+xml,${svg}' };
+const t = (k) => k;
+const goBack = (fn) => { fn(); render(); };
+function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); }
+window.test = { ui, render, open() { ui.lightbox = 'data:image/svg+xml,${svg}'; render(); } };
+render();
+`;
+const bundle = await Bun.build({ entrypoints: ['zoom-entry'], target: 'browser', plugins: [{ name: 'zoom', setup(build) {
+ build.onResolve({ filter: /^zoom-entry$/ }, () => ({ path: 'entry', namespace: 'zoom' }));
+ build.onLoad({ filter: /.*/, namespace: 'zoom' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+} }] });
+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,maximum-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: () => 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));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const zoom = () => page.evaluate(() => { const m = new DOMMatrix(getComputedStyle(document.querySelector('.lightbox img')).transform); return { s: +m.a.toFixed(2), x: Math.round(m.e), y: Math.round(m.f) }; });
+const open = () => page.evaluate(() => !!test.ui.lightbox && !!document.querySelector('.lightbox'));
+try {
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.goto(server.url.href);
+ await page.waitForSelector('.lightbox img');
+ const cdp = await page.target().createCDPSession();
+ const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) });
+ const cx = 195, cy = 422;
+ // pinch out around the centre
+ await touch('touchStart', [[cx - 40, cy], [cx + 40, cy]]);
+ for (let i = 1; i <= 8; i++) { await touch('touchMove', [[cx - 40 - i * 15, cy], [cx + 40 + i * 15, cy]]); await sleep(16); }
+ await touch('touchEnd', []); await sleep(350);
+ let z = await zoom();
+ assert(z.s > 3.8 && z.s < 4.2, 'pinch zooms with the fingers (80px → 320px apart = 4×): ' + JSON.stringify(z));
+ assert(await open(), 'a pinch does not close the picture');
+ // a background render keeps the zoom
+ await page.evaluate(() => test.render()); await sleep(50);
+ assert.equal((await zoom()).s, z.s, 'a background render keeps the zoom');
+ // one finger pans the zoomed picture, clamped to its edges
+ await touch('touchStart', [[cx, cy]]);
+ for (let i = 1; i <= 10; i++) { await touch('touchMove', [[cx + i * 20, cy + i * 5]]); await sleep(16); }
+ await touch('touchEnd', []); await sleep(350);
+ const panned = await zoom();
+ assert(panned.x > z.x + 50, 'drag pans: ' + JSON.stringify(panned));
+ const maxX = await page.evaluate((s) => (document.querySelector('.lightbox img').offsetWidth * s - innerWidth) / 2, panned.s);
+ assert(panned.x <= maxX + 1, 'pan is held to the picture edge');
+ assert(await open(), 'a drag does not close the picture');
+ // a tap while zoomed does not close; a double-tap resets
+ await page.touchscreen.tap(cx, cy); await sleep(400);
+ assert(await open(), 'a tap while zoomed does not close');
+ await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350);
+ assert.equal((await zoom()).s, 1, 'double-tap resets the zoom');
+ // double-tap at 1× zooms in at the spot, without closing
+ await page.touchscreen.tap(cx + 60, cy); await sleep(80); await page.touchscreen.tap(cx + 60, cy); await sleep(400);
+ z = await zoom();
+ assert(z.s === 2.5 && z.x < 0, 'double-tap zooms in toward the tapped spot: ' + JSON.stringify(z));
+ assert(await open(), 'a double-tap does not close');
+ await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350);
+ // a single tap at 1× still closes
+ await page.touchscreen.tap(cx, cy); await sleep(450);
+ assert(!(await open()), 'a single tap closes the picture');
+ // desktop: the wheel zooms toward the cursor
+ await page.setViewport({ width: 1280, height: 800 });
+ await page.evaluate(() => test.open()); await page.waitForSelector('.lightbox img');
+ await page.mouse.move(700, 400);
+ for (let i = 0; i < 5; i++) { await page.mouse.wheel({ deltaY: -120 }); await sleep(30); }
+ z = await zoom();
+ assert(z.s > 1.5, 'the wheel zooms in: ' + JSON.stringify(z));
+ assert(await open(), 'wheel zoom leaves the picture open');
+ assert.deepEqual(errors, []);
+ console.log('✓ Image viewer: pinch zooms, drag pans within the edges, double-tap zooms and resets, renders keep the zoom, gestures never close it, a tap does, the wheel zooms on desktop');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js
new file mode 100644
index 0000000..e587e41
--- /dev/null
+++ b/tools/media-upload-test.js
@@ -0,0 +1,61 @@
+import { uploadPublicMedia, MediaUploadError } from '../src/media-upload.js';
+import { sha256 } from '@noble/hashes/sha256';
+import { bytesToHex } from '@noble/hashes/utils';
+
+let ok = true;
+const check = (name, pass) => { console.log(`${pass ? '✓' : '✗'} ${name}`); if (!pass) ok = false; };
+const bytes = new TextEncoder().encode('phone video bytes');
+const file = new File([bytes], 'clip.mov', { type: 'video/quicktime' });
+let signed = null;
+const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) });
+const calls = [];
+const fetcher = async (url, init) => {
+ if (init?.method === 'HEAD') return new Response('', { status: 200 }); // the reachability probe
+ calls.push({ url, init });
+ if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 });
+ return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } });
+};
+
+const url = await uploadPublicMedia(file, sign, { servers: ['https://first', 'https://second/'], fetcher, timeoutMs: 1000 });
+check('falls back to a second independent media host', url === 'https://cdn.example/clip.mov' && calls.length === 2);
+check('uploads the original video type and bytes', calls[1].init.method === 'PUT' && calls[1].init.body === file
+ && calls[1].init.headers['content-type'] === 'video/quicktime');
+check('hashes the file and binds the hash into Blossom auth', calls[1].init.headers['x-sha-256'] === bytesToHex(sha256(bytes))
+ && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256']));
+check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr '));
+
+// a host that never answers is skipped after the probe, not waited on
+{
+ const puts = [];
+ const t0 = Date.now();
+ const u = await uploadPublicMedia(file, sign, {
+ servers: ['https://dead.example', 'https://alive.example'], timeoutMs: 60_000,
+ fetcher: async (url, init) => {
+ if (url.startsWith('https://dead')) return new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(new Error('timeout'))));
+ if (init.method === 'HEAD') return new Response('', { status: 404 });
+ puts.push(url);
+ return new Response(JSON.stringify({ url: 'https://alive.example/x.mov' }), { status: 201 });
+ },
+ });
+ check('an unreachable host is skipped within seconds', u === 'https://alive.example/x.mov' && Date.now() - t0 < 7000 && puts.length === 1);
+}
+check('ditto is the first host', (await import('../src/media-upload.js')).PUBLIC_MEDIA_SERVERS[0] === 'https://blossom.ditto.pub');
+
+const bareHashUrl = await uploadPublicMedia(file, sign, {
+ servers: ['https://media.example'], timeoutMs: 1000,
+ fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }),
+});
+check('adds the video extension when a Blossom server returns a bare hash URL', bareHashUrl.endsWith('.mov'));
+
+try {
+ await uploadPublicMedia(file, sign, {
+ servers: ['https://one', 'https://two'], timeoutMs: 1000,
+ fetcher: async () => new Response('', { status: 413 }),
+ });
+ check('reports provider size rejection', false);
+} catch (error) {
+ check('reports provider size rejection', error instanceof MediaUploadError && error.code === 'too-big');
+}
+
+console.log(ok ? '\n✅ public media upload works' : '\n❌ public media upload failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/notif-note-cache-test.js b/tools/notif-note-cache-test.js
new file mode 100644
index 0000000..c0ceeca
--- /dev/null
+++ b/tools/notif-note-cache-test.js
@@ -0,0 +1,128 @@
+// Referenced notification notes are fetched before the page opens, persisted,
+// and available on the first paint after a reload.
+// Run: bun tools/notif-note-cache-test.js
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.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((resolve) => setTimeout(resolve, ms));
+let ok = true;
+const check = (name, pass, detail = '') => {
+ console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
+ if (!pass) ok = false;
+};
+const now = Math.floor(Date.now() / 1000);
+const actorSk = generateSecretKey();
+const actorPk = getPublicKey(actorSk);
+const noteSk = generateSecretKey();
+const noteText = 'This notification excerpt came from the local note cache.';
+const target = finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: noteText }, noteSk);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({
+ port: 5307,
+ 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 browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((targetEvent) => {
+ window.__targetReqs = 0;
+ const Real = window.WebSocket;
+ window.WebSocket = function (...args) {
+ const ws = new Real(...args);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const msg = JSON.parse(data);
+ if (msg[0] === 'REQ') {
+ const filter = msg[2] || {};
+ const hit = (filter.ids || []).includes(targetEvent.id);
+ if (hit) window.__targetReqs++;
+ setTimeout(() => {
+ if (hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', msg[1], targetEvent]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', msg[1]]) }));
+ }, 0);
+ return;
+ }
+ if (msg[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, target);
+
+const clickText = (text) => page.evaluate((needle) => {
+ const el = [...document.querySelectorAll('button')].find((node) => node.textContent.trim().toLowerCase().includes(needle));
+ if (!el) return false;
+ el.click(); return true;
+}, text);
+const waitText = async (text, timeout = 20_000) => {
+ for (let elapsed = 0; elapsed < timeout; elapsed += 200) {
+ if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(text.toLowerCase())) return true;
+ await sleep(200);
+ }
+ return false;
+};
+const openNotifications = () => page.evaluate(() => {
+ const el = [...document.querySelectorAll('button')].find((node) => /notifications/i.test(node.getAttribute('aria-label') || ''));
+ if (!el) return false;
+ el.click(); return true;
+});
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5307/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await clickText('create a new wallet');
+ await page.waitForSelector('.words .w .t');
+ const mnemonic = (await page.$$eval('.words .w .t', (nodes) => nodes.map((node) => node.textContent.trim()))).join(' ');
+ await clickText('skip verification');
+ await waitText('receive');
+ const base = cacheKeyFor(mnemonic + '\n');
+ const me = getPublicKey(privateKeyFromSeedWords(mnemonic));
+ const item = { id: 'a'.repeat(64), what: 'react', actor: actorPk, target: target.id, hint: [], emoji: '❤️', ts: now - 30 };
+ await page.evaluate(([key, owner, notification]) => {
+ const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}');
+ state.notifs = [notification]; state.notifsPk = owner;
+ localStorage.setItem(key + ':messages', JSON.stringify(state));
+ }, [base, me, item]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+
+ let cached = false;
+ for (let i = 0; i < 75; i++) {
+ cached = await page.evaluate(([key, id]) => {
+ const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}');
+ return state.notifNoteCache && state.notifNoteCache[id]?.content;
+ }, [base, target.id]);
+ if (cached) break;
+ await sleep(200);
+ }
+ check('the referenced note is warmed and persisted', cached === noteText);
+ check('the relay was queried while the page was still closed', (await page.evaluate(() => window.__targetReqs)) > 0);
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ check('notifications opens from the bottom navigation', await openNotifications());
+ await sleep(80);
+ const firstPaint = await page.evaluate(() => document.body.innerText);
+ check('the cached excerpt is present immediately', firstPaint.includes('This notification excerpt'));
+ check('there is no Fetching note placeholder', !firstPaint.includes('Fetching note'));
+ check('the persisted note avoids another target query', (await page.evaluate(() => window.__targetReqs)) === 0);
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+
+console.log(ok ? '\n✅ notification note cache works' : '\n❌ notification note cache failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/poll-test.js b/tools/poll-test.js
new file mode 100644
index 0000000..b88a4db
--- /dev/null
+++ b/tools/poll-test.js
@@ -0,0 +1,93 @@
+// Polls (NIP-88, kind 1068): shown as their options with each one's share of
+// the vote (kind 1018), votable from the feed and the thread, and pictured
+// in a quote card. Uses a real poll from the relays; outgoing votes are
+// caught at the socket so the test never votes on someone's real poll.
+//
+// Run: bun tools/poll-test.js
+import puppeteer from 'puppeteer-core';
+import { SimplePool } from 'nostr-tools/pool';
+import { cacheKeyFor } from '../src/wallet.js';
+import { neventOf } from '../src/nostr.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 POLL_ID = '000002f7c6a38223a9843d6e93ce8c15a3048bc95003690d74341cf9de372bfc';
+const pool = new SimplePool();
+const [poll] = await pool.querySync(['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com'], { ids: [POLL_ID] }, { maxWait: 6000 });
+if (!poll) { console.log(' ✗ the poll is not on the relays'); process.exit(1); }
+const labels = poll.tags.filter((x) => x[0] === 'option').map((x) => x[2]);
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5273, 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();
+await page.evaluateOnNewDocument(() => {
+ window.__votes = [];
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ if (typeof d === 'string' && d.startsWith('["EVENT"') && /"kind":1018\b/.test(d)) { window.__votes.push(JSON.parse(d)[1]); return; }
+ return send.call(this, d);
+ };
+});
+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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5273/', { 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 Q = 'a'.repeat(63) + '9';
+ await page.evaluate(([k, poll, q, ref]) => {
+ const now = Math.floor(Date.now() / 1000);
+ const quoting = { id: 'd'.repeat(64), pubkey: q, kind: 1, created_at: now, content: 'vote on this nostr:' + ref, tags: [] };
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', poll.pubkey], ['p', q]], c: '', at: now }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify([quoting, poll]));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [poll.pubkey]: { name: 'Poll Author', t: Date.now() }, [q]: { name: 'Quoter', t: Date.now() } }));
+ }, [base, poll, Q, neventOf(poll.id, poll.pubkey)]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await waitFor(() => document.querySelectorAll('.notes-feed .poll').length >= 2, 20000);
+
+ const feedPoll = await page.evaluate(() => {
+ const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.querySelector(':scope .poll button.poll-opt'));
+ return row && { opts: [...row.querySelectorAll('.poll-opt .poll-label')].map((n) => n.textContent), text: row.innerText };
+ });
+ check('a poll in the feed shows its options', !!feedPoll && JSON.stringify(feedPoll.opts) === JSON.stringify(labels), JSON.stringify(feedPoll?.opts));
+ check('...and not the "doesn’t show" placeholder', !(await page.evaluate(() => document.body.innerText.includes('doesn’t show'))));
+ check('its votes come in from the relays', await waitFor(() => /\d+ votes?/.test(document.querySelector('.notes-feed .poll-foot')?.textContent || ''), 15000),
+ await page.evaluate(() => document.querySelector('.notes-feed .poll-foot')?.textContent));
+ const quoted = await page.evaluate(() => { const q = document.querySelector('.quote-card .poll'); return q && { n: q.querySelectorAll('.poll-opt.static').length, buttons: q.querySelectorAll('button').length }; });
+ check('a quoted poll is pictured in its card, not votable there', !!quoted && quoted.n === labels.length && quoted.buttons === 0, JSON.stringify(quoted));
+
+ // vote for the last option from the feed
+ const before = await page.evaluate(() => location.href);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed button.poll-opt')].pop(); b.click(); });
+ await waitFor(() => window.__votes.length > 0, 10000);
+ const vote = await page.evaluate(() => window.__votes[0]);
+ const lastId = poll.tags.filter((x) => x[0] === 'option').pop()[1];
+ check('tapping an option publishes a kind 1018 vote for it', vote && vote.kind === 1018
+ && vote.tags.some((x) => x[0] === 'e' && x[1] === poll.id) && vote.tags.filter((x) => x[0] === 'response').map((x) => x[1]).join() === lastId,
+ JSON.stringify(vote?.tags));
+ check('...marked as mine at once', await waitFor(() => !!document.querySelector('.notes-feed button.poll-opt.mine'), 3000));
+ check('...without opening the thread', (await page.evaluate(() => location.href)) === before);
+
+ // the thread: the poll as its root
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.querySelector(':scope .poll button.poll-opt')); r.querySelector('.note-text').click(); });
+ check('the poll opens as a thread', await waitFor(() => !!document.querySelector('.thread-page .poll button.poll-opt'), 10000));
+ check('...still showing my vote', await page.evaluate(() => !!document.querySelector('.thread-page .poll-opt.mine')));
+ if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(500); await page.screenshot({ path: process.env.SHOT }); }
+ check('...with its replies', await waitFor(() => document.querySelectorAll('.thread-page [data-zap-post]').length > 1, 15000));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ polls read and vote' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js
index d8c4c84..17f8372 100644
--- a/tools/post-actions-test.js
+++ b/tools/post-actions-test.js
@@ -81,7 +81,7 @@ try {
// quoting carries the post into the composer as a nostr: reference
await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Quote'); b.click(); });
await sleep(900);
- const draft = await page.evaluate(() => (document.querySelector('.chat-page textarea') || {}).value || '');
+ const draft = await page.evaluate(() => (document.querySelector('.chat-page coinos-text') || {}).value || '');
check('quoting opens the composer with the reference in it', /nostr:nevent1/.test(draft), draft.slice(0, 40) || 'empty');
// A reply can carry a picture, the same as a post can.
diff --git a/tools/post-cancel-test.js b/tools/post-cancel-test.js
new file mode 100644
index 0000000..217e743
--- /dev/null
+++ b/tools/post-cancel-test.js
@@ -0,0 +1,20 @@
+// Canceling a new post closes the composer and discards every part of its
+// saved state, including a late attachment result.
+import assert from 'node:assert/strict';
+
+const source = await Bun.file('src/features/messages.js').text();
+const start = source.indexOf('function cancelPost()');
+const end = source.indexOf('\n }', start);
+const cancel = source.slice(start, end);
+assert(start > 0, 'post cancel handler exists');
+assert(cancel.includes('postAttachGeneration++'), 'late uploads are invalidated');
+assert(cancel.includes('ui.profCompose = null'), 'composer is closed');
+assert(cancel.includes('ui.postMedia = []'), 'pending attachment metadata is removed');
+assert(cancel.includes('ui.postPreview = false'), 'preview state is removed');
+assert(cancel.includes('discardDraft(POST_DRAFT)'), 'persisted draft is removed immediately');
+assert(source.includes("composeText().length > 20"), 'drafts over 20 characters require confirmation');
+assert(source.includes("ui.postUploading || (ui.postMedia || []).length"), 'attachments require confirmation');
+assert(source.includes("onClick: requestCancelPost }, t('cancel')"), 'Cancel button uses the guarded discard handler');
+assert(source.includes("onClick: cancelPost }, t('postDiscard')"), 'warning can confirm the discard');
+
+console.log('✓ Cancel warns for substantial drafts, then closes the form and removes its complete saved draft');
diff --git a/tools/quote-card-test.js b/tools/quote-card-test.js
index 27a1b22..d2d7d7e 100644
--- a/tools/quote-card-test.js
+++ b/tools/quote-card-test.js
@@ -25,11 +25,24 @@ const found = await pool.querySync(['wss://relay.primal.net', 'wss://nos.lol'],
const target = (found || []).filter((e) => e.content && e.content.length > 20)[0];
if (!target) { console.log(' ✗ no note to quote (relays quiet)'); process.exit(1); }
const nevent = neventOf(target.id, target.pubkey);
+const FAR = 'nevent1qvzqqqqqqypzqntcggz30qhq60ltqdx32zku9d46unhrkjtcv7fml7jx3dh4h94nqqsqchs0pz4hnqm6jurakjwcd6wcrph7wyy80j0ljup68p4hynvsz3q3nhre2';
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5269, 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();
+// --slow: the relays miss the quoted note the first time it is asked for
+// (ours was down for an evening once), so the row freezes with the fallback
+// link; the next ask, half a minute on, finds it.
+const SLOW_MS = 32000;
+await page.evaluateOnNewDocument((id) => {
+ const send = WebSocket.prototype.send;
+ const t0 = Date.now();
+ WebSocket.prototype.send = function (d) {
+ if (localStorage.getItem('__slowQuote') && typeof d === 'string' && d.includes(id) && d.startsWith('["REQ"') && Date.now() - t0 < 20000) return;
+ return send.call(this, d);
+ };
+}, target.id);
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 {
@@ -47,18 +60,26 @@ try {
await waitText('receive', 20000);
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9';
- await page.evaluate(([k, pk, ref]) => {
+ await page.evaluate(([k, pk, ref, FAR]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) }));
localStorage.setItem(k + ':feedNotes', JSON.stringify([
{ id: 'd'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'what he said nostr:' + ref, tags: [] },
+ // quotes a note that sits only on nostr.mom — not in the reference, not in its author's relay list
+ { id: 'e'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000) - 60, content: 'I remember this.\n\nnostr:' + FAR, tags: [] },
]));
- }, [base, A, nevent]);
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Quoter', t: Date.now() } }));
+ }, [base, A, nevent, FAR]);
+ if (process.argv.includes('--slow')) await page.evaluate(() => localStorage.setItem('__slowQuote', '1'));
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
- await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
- await sleep(1000);
- await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
await sleep(7000);
+ if (process.argv.includes('--slow')) {
+ for (let i = 0; i < 40 && !(await page.evaluate(() => !!document.querySelector('.quote-card'))); i++) await sleep(250);
+ check('a late quote first paints as a placeholder card, not a link', await page.evaluate(() => !document.body.innerText.includes('view note')
+ && [...document.querySelectorAll('.quote-card')].some((q) => /Couldn.t find|Fetching note/.test(q.innerText))));
+ await sleep(SLOW_MS + 4000);
+ }
const card = await page.evaluate(() => {
const q = document.querySelector('.quote-card');
@@ -70,7 +91,12 @@ try {
check('...with whose note it is', !!(card && card.hasAvatar), card ? card.text.slice(0, 50) : '');
const body = target.content.replace(/\s+/g, ' ').slice(0, 24);
check('...and what it actually said', !!(card && card.text.replace(/\s+/g, ' ').includes(body.slice(0, 18))), body);
- check('no "view note" link left in the post', !(await page.evaluate(() => document.body.innerText)).includes('view note'));
+ if (!process.argv.includes('--slow')) {
+ const far = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('Quoter') && n.innerText.includes('I remember this')); return r && r.querySelector('.quote-card')?.innerText; });
+ check('a quote found only on a far relay still shows as its card', !!far && !/Couldn.t find|Fetching/.test(far), JSON.stringify((far || '').slice(0, 60)));
+ }
+ // (a quote inside a quote stays a link on purpose: one level of cards)
+ check('no "view note" link left in the post', !(await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].some((a) => a.textContent === 'view note' && !a.closest('.quote-card')))));
// the outer post keeps its actions; the quote has none of its own
const acts = await page.evaluate(() => ({
outer: document.querySelectorAll('.notes-feed > .row .note-acts').length,
diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js
new file mode 100644
index 0000000..ab673ff
--- /dev/null
+++ b/tools/room-faces-test.js
@@ -0,0 +1,88 @@
+// Opening a community must not paint everyone as a punk and then swap in
+// their real pictures: faces are warmed on hover/press (and while the list
+// sits there) and the tap waits a moment for them. A fresh wallet has no
+// profiles cached — the worst case — against the real coinos community.
+//
+// Run: bun tools/room-faces-test.js [--no-hover]
+import puppeteer from 'puppeteer-core';
+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: 5277, 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 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:5277/', { 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.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click());
+ await waitText('members', 20000);
+ // let the community's history arrive (the list shows before it has)
+ await sleep(process.argv.includes('--no-hover') ? 1500 : 6000);
+ const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')]
+ .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other');
+ // name -> face state, per author (rows stream in, so positions shift)
+ const keyed = () => { const o = {}; for (const r of document.querySelectorAll('.chat-log .chat-row')) {
+ const a = r.querySelector(':scope > .chat-avatar:not(.spacer), :scope > .hat-wrap > .chat-avatar'); const n = r.querySelector('.chat-meta')?.firstElementChild?.textContent;
+ if (a && n) o[n] = a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'; } return o; };
+ await page.evaluate((fs) => {
+ const face = new Function('return ' + fs)();
+ const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } });
+ mo.observe(document.body, { childList: true, subtree: true });
+ }, face.toString());
+ const row = await page.evaluateHandle(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => /members/.test(r.innerText)));
+ const t0 = await page.evaluate(() => performance.now());
+ if (!process.argv.includes('--no-hover')) await row.hover();
+ await row.click();
+ for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100);
+ await sleep(1200);
+ const at1s = await page.evaluate(new Function('return (' + face.toString() + ')()'));
+ check('no face is still a blank circle a second after the room shows', !at1s.includes('wait'), JSON.stringify(tally(at1s)));
+ await sleep(4800);
+ const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]);
+ const last = await page.evaluate(new Function('return (' + face.toString() + ')()'));
+ const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length;
+ console.log(' first paint after', Math.round(at - t0), 'ms:', JSON.stringify(tally(first)), ' settled:', JSON.stringify(tally(last)));
+ check('the room opens with its messages', (first || []).length > 0);
+ check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped');
+ check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms');
+
+ if (process.argv.includes('--refresh')) {
+ // a reload inside the room: every face as it will stay, from the first
+ // frame — a known-faceless member is their punk at once, not a blank
+ // circle that turns into one. (Wait out the second ask that confirms
+ // who has no profile.)
+ await sleep(8000);
+ await page.evaluateOnNewDocument((fs, ks) => {
+ const face = new Function('return ' + fs)();
+ const keyed = new Function('return ' + ks)();
+ const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstBy = keyed(); } });
+ addEventListener('DOMContentLoaded', () => mo.observe(document.body, { childList: true, subtree: true }));
+ }, face.toString(), keyed.toString());
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ for (let i = 0; i < 100 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100);
+ await sleep(6000);
+ const f2 = await page.evaluate(() => window.__first);
+ const l2 = await page.evaluate(new Function('return (' + face.toString() + ')()'));
+ const byName = await page.evaluate(new Function('return (' + keyed.toString() + ')()'));
+ const firstBy = await page.evaluate(() => window.__firstBy);
+ const changedWho = Object.keys(firstBy || {}).filter((k) => byName[k] && firstBy[k] !== byName[k] && !(firstBy[k] === 'punk' && byName[k] === 'other'));
+ if (changedWho.length) console.log(' changed:', changedWho.map((k) => k + ' ' + firstBy[k] + '→' + byName[k]).join(', '));
+ const changed = changedWho.length;
+ console.log(' after reload, first paint:', JSON.stringify(tally(f2)), ' settled:', JSON.stringify(tally(l2)));
+ check('after a reload in the room, no face changes once painted', !!f2 && changed === 0, changed + ' changed');
+ }
+} finally { await browser.close(); server.stop(true); }
+function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; }
+console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
index 485e874..6bac353 100644
--- a/tools/thread-cache-test.js
+++ b/tools/thread-cache-test.js
@@ -19,9 +19,17 @@ check('a fresh store restores full content and tags by any viewed event', () =>
const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] };
store.save({ root, replies: [parent, long] }, long.id);
const restored = createThreadStore(storage).find(long.id);
- assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent] });
+ assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] });
assert.equal(store.find(parent.id).root.id, root.id);
});
+check('a thread rooted in a poll (kind 1068) is kept too', () => {
+ const poll = { ...note(40), kind: 1068, tags: [['option', '0', 'Yes!'], ['option', '1', 'No!']] };
+ const answer = note(41, [['e', poll.id, '', 'root']]);
+ store.save({ root: poll, replies: [answer] }, poll.id);
+ const restored = createThreadStore(storage).find(poll.id);
+ assert.equal(restored?.root.kind, 1068);
+ assert.deepEqual(restored.replies, [answer]);
+});
check('thread authors keep their names and local avatar thumbnails across reloads', () => {
const thumb = 'data:image/webp;base64,AAAA';
const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50,
@@ -36,6 +44,12 @@ check('thread action totals are present before relay refresh and survive a conte
store.save({ root, replies: [parent, reply] }, reply.id);
assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 });
});
+check('posts quoted inside the thread are restored with it, and a later save keeps them', () => {
+ const q = note(77), poll = { ...note(78), kind: 1068 }; // a quote can be any kind
+ store.save({ root, replies: [parent, reply] }, reply.id, {}, {}, [q, poll]);
+ store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh with no quotes resolved yet
+ assert.deepEqual(createThreadStore(storage).find(reply.id).quotes, [q, poll]);
+});
check('a large thread retains the selected reply and its ancestors', () => {
const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']]));
store.save({ root, replies: [...siblings, parent, reply] }, reply.id);
@@ -70,7 +84,8 @@ try {
const { messagesFeature } = await import(path);
let requests = [], waiting = [];
globalThis.__threadQuery = (filter) => {
- if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) {
+ // root lookups by id carry no kind (a root can be a poll); reply queries are kind 1
+ if (filter.ids || (filter.kinds?.includes(1) && filter['#e'])) {
requests.push(filter);
return new Promise((resolve) => waiting.push({ filter, resolve }));
}
diff --git a/tools/topic-depth-test.js b/tools/topic-depth-test.js
new file mode 100644
index 0000000..9876224
--- /dev/null
+++ b/tools/topic-depth-test.js
@@ -0,0 +1,36 @@
+// A topic feed (#gardenstr) goes back as far as the relays remember: the
+// big relays prune, so topics also ask archive-keeping ones, and a page
+// whose posts were all filtered out (replies, muted, spam) is not the end.
+// Before: ~5 posts, then "You're all caught up".
+//
+// Run: bun tools/topic-depth-test.js
+import puppeteer from 'puppeteer-core';
+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: 5285, 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();
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5285/feed/t/gardenstr', { waitUntil: 'domcontentloaded' });
+ const count = () => page.evaluate(() => new Set([...document.querySelectorAll('.notes-feed [data-key]')].map((n) => n.getAttribute('data-key'))).size);
+ for (let i = 0; i < 60 && (await count()) < 3; i++) await sleep(500);
+ const first = await count();
+ check('the topic opens with posts', first >= 3, first + ' posts');
+ let seen = new Set(), ended = false;
+ const t0 = Date.now();
+ while (Date.now() - t0 < 90_000 && seen.size < 60) {
+ for (const k of await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].map((n) => n.getAttribute('data-key')))) seen.add(k);
+ ended = await page.evaluate(() => document.body.innerText.includes('all caught up'));
+ if (ended) break;
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(1500);
+ }
+ check('scrolling keeps finding older posts', seen.size >= 40, seen.size + ' posts' + (ended ? ', then "caught up"' : ''));
+ check('...without claiming to be caught up after a handful', !ended || seen.size >= 40);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a topic goes back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/upload-progress-test.js b/tools/upload-progress-test.js
new file mode 100644
index 0000000..25bf91c
--- /dev/null
+++ b/tools/upload-progress-test.js
@@ -0,0 +1,37 @@
+// uploadPublicMedia reports how much of the file has gone out (XMLHttpRequest
+// upload events — fetch() can't), from 0 to 1, on a slow uplink.
+//
+// Run: bun tools/upload-progress-test.js
+import puppeteer from 'puppeteer-core';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const js = await Bun.build({ entrypoints: ['src/media-upload.js'], target: 'browser' }).then((b) => b.outputs[0].text());
+let got = 0;
+const server = Bun.serve({ port: 5289, async fetch(req) {
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD', 'access-control-allow-headers': '*', 'access-control-expose-headers': 'x-reason' };
+ const p = new URL(req.url).pathname;
+ if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
+ if (p === '/upload' && req.method === 'PUT') { got = (await req.arrayBuffer()).byteLength; return Response.json({ url: 'http://localhost:5289/' + 'a'.repeat(64) }, { status: 201, headers: cors }); }
+ if (p === '/m.js') return new Response(js, { headers: { 'content-type': 'text/javascript' } });
+ return new Response('<script type=module>import * as m from "/m.js"; window.m = m;</script>', { 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();
+try {
+ await page.goto('http://localhost:5289/');
+ await page.waitForFunction(() => !!window.m);
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 20, downloadThroughput: 5e6, uploadThroughput: 400e3 });
+ const r = await page.evaluate(async () => {
+ const seen = [];
+ const file = new File([new Uint8Array(1_200_000)], 'v.mp4', { type: 'video/mp4' });
+ const sign = async (e) => ({ ...e, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) });
+ const url = await window.m.uploadPublicMedia(file, sign, { servers: ['http://localhost:5289'], onProgress: (p) => seen.push(Math.round(p * 100)) });
+ return { url, seen };
+ });
+ check('the upload lands', r.url.startsWith('http://localhost:5289/') && got === 1_200_000, got + ' bytes');
+ check('progress climbs from 0 to 100 as the bytes go', r.seen[0] === 0 && r.seen.at(-1) === 100 && r.seen.filter((p) => p > 0 && p < 100).length >= 3
+ && r.seen.every((p, i) => !i || p >= r.seen[i - 1]), JSON.stringify(r.seen.slice(0, 15)) + (r.seen.length > 15 ? '…' : ''));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ uploads report their progress' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js
new file mode 100644
index 0000000..ce1a936
--- /dev/null
+++ b/tools/video-compress-test.js
@@ -0,0 +1,126 @@
+// A phone video is shrunk on the device before upload: 1080p at 20 Mbps in,
+// H.264 720p around 2.5 Mbps out, moov first (streams while downloading),
+// audio kept. Pasted into the reply box like a keyboard/gallery pick; the
+// upload is caught and its bytes checked with ffprobe.
+//
+// Run: bun tools/video-compress-test.js
+import puppeteer from 'puppeteer-core';
+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 SRC = '/tmp/coinos-video-compress-src.mp4', OUT = '/tmp/coinos-video-compress-out.mp4';
+// --portrait: the way phones record upright video — landscape frames plus a
+// 90° rotation flag in the container
+const PORTRAIT = process.argv.includes('--portrait');
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '5', '-c:v', 'libx264', '-b:v', '20M', '-maxrate', '20M', '-bufsize', '20M', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', SRC]);
+if (PORTRAIT) {
+ // (ffmpeg 4 writes the flag only on a remux)
+ Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-i', SRC, '-c', 'copy', '-metadata:s:v:0', 'rotate=90', SRC + '.rot.mp4']);
+ Bun.spawnSync(['mv', SRC + '.rot.mp4', SRC]);
+ const r = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', SRC]).stdout)).streams[0];
+ console.log(' source rotation:', JSON.stringify(r.side_data_list || r.tags?.rotate || 'none'));
+}
+const srcSize = Bun.file(SRC).size;
+// the split build: the compressor and Mediabunny are chunks loaded on demand
+const built = Bun.spawnSync(['bun', 'run', 'build.js'], { stdout: 'pipe', stderr: 'pipe' });
+if (built.exitCode) { console.log(new TextDecoder().decode(built.stderr)); process.exit(1); }
+const dist = 'dist';
+const server = Bun.serve({ port: 5287, fetch: async (req) => {
+ const p = new URL(req.url).pathname;
+ if (p === '/src.mp4') return new Response(Bun.file(SRC), { headers: { 'content-type': 'video/mp4' } });
+ const f = Bun.file(dist + (p === '/' || !p.includes('.') ? '/index.html' : p));
+ return (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();
+// the upload goes out by XMLHttpRequest (for its progress events): keep a
+// copy of the body, and answer at the network layer — nothing leaves
+await page.evaluateOnNewDocument(() => {
+ const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (m, url) { this.__m = m; this.__url = String(url); return open.apply(this, arguments); };
+ XMLHttpRequest.prototype.send = function (body) {
+ if (this.__m === 'PUT' && /nostr\.build|nostr\.download/.test(this.__url) && body instanceof Blob) {
+ body.arrayBuffer().then((buf) => { window.__put = { size: body.size, type: body.type, buf }; });
+ }
+ return send.apply(this, arguments);
+ };
+ // every state the progress bar shows
+ window.__prog = [];
+ new MutationObserver(() => {
+ const m = document.querySelector('.media-progress');
+ const s = m ? m.innerText.replace(/\s+/g, ' ').trim() : null;
+ if (s && window.__prog.at(-1) !== s) window.__prog.push(s);
+ }).observe(document, { childList: true, subtree: true, characterData: true, attributes: true });
+});
+await page.setRequestInterception(true);
+page.on('request', (req) => {
+ const u = req.url();
+ if (!/nostr\.build|nostr\.download/.test(u)) return req.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' };
+ if (req.method() === 'OPTIONS') return req.respond({ status: 204, headers: cors });
+ if (req.method() === 'HEAD') return req.respond({ status: 200, headers: cors });
+ return req.respond({ status: 201, headers: cors, contentType: 'application/json',
+ body: JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }) });
+});
+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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5287/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(600);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported));
+ // the post composer: the pencil on the feed
+ await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click());
+ await sleep(1500);
+ await page.evaluate(() => document.querySelector('button[aria-label="New post"], button[title="New post"]')?.click());
+ const box = await waitFor(() => !!document.querySelector('.post-input'), 8000);
+ check('the post composer opens', box);
+ const t0 = Date.now();
+ await page.evaluate(async () => {
+ const b = await (await fetch('/src.mp4')).blob();
+ const dt = new DataTransfer();
+ dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' }));
+ document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
+ });
+ const done = await waitFor(() => !!window.__put, 120000);
+ await waitFor(() => !document.querySelector('.media-progress'), 10000);
+ const prog = await page.evaluate(() => window.__prog);
+ const pcts = (stage) => prog.filter((x) => x.startsWith(stage)).map((x) => +x.match(/(\d+)%/)[1]);
+ const shrink = pcts('Shrinking video'), up = pcts('Uploading');
+ check('a bar shows the shrinking with its percentage', shrink.length >= 3 && shrink.some((p) => p > 0 && p < 100), JSON.stringify(shrink.slice(0, 12)));
+ // (the answer here is instant, so only its start shows — the percentages
+ // themselves are upload-progress-test's)
+ check('...then the upload with its own', up.length >= 1 && prog.findIndex((x) => x.startsWith('Uploading')) > prog.findIndex((x) => x.startsWith('Shrinking')), JSON.stringify(up));
+ check('...and goes away when it is done', await page.evaluate(() => !document.querySelector('.media-progress')));
+ const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type }));
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
+ check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35,
+ put ? `${(srcSize / 1e6).toFixed(1)} MB → ${(put.size / 1e6).toFixed(1)} MB in ${secs}s` : 'no upload');
+ if (put) {
+ const b64 = await page.evaluate(() => { let s = ''; const u = new Uint8Array(window.__put.buf); for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); });
+ await Bun.write(OUT, Buffer.from(b64, 'base64'));
+ const probe = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', OUT]).stdout));
+ const v = probe.streams.find((s) => s.codec_type === 'video'), a = probe.streams.find((s) => s.codec_type === 'audio');
+ check('...H.264 at 720p', v?.codec_name === 'h264' && Math.min(v.width, v.height) === 720, `${v?.codec_name} ${v?.width}x${v?.height}`);
+ if (PORTRAIT) {
+ // upright on screen: either the frames are portrait, or they keep the rotation flag
+ const rot = Math.abs(+((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0));
+ check('...still upright (portrait)', (v.height > v.width) !== (rot === 90 || rot === 270), `${v.width}x${v.height} rotation ${rot}`);
+ }
+ check('...around 2.5 Mbps', v && +v.bit_rate < 3.5e6, (+v?.bit_rate / 1e6).toFixed(2) + ' Mbps');
+ check('...the whole clip, with its sound', Math.abs(+probe.format.duration - 5) < 0.3 && !!a, probe.format.duration + 's, audio ' + a?.codec_name);
+ const head = new Uint8Array(await Bun.file(OUT).slice(0, 4096).arrayBuffer());
+ const txt = new TextDecoder('latin1').decode(head);
+ check('...moov before mdat, so it plays while downloading', txt.indexOf('moov') >= 0 && (txt.indexOf('mdat') < 0 || txt.indexOf('moov') < txt.indexOf('mdat')));
+ }
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ videos are shrunk before upload' : '\n❌ failed');
+process.exit(ok ? 0 : 1);