From b11ae2e1811bd5dc6965d127414d8f4f03f248cd Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 13 Sep 2026 14:55:18 -0700 Subject: The feed keeps up, and stops moving the page while you read. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone freezes a backgrounded tab: timers stop and the relay sockets are cut, so the feed came back holding whatever it had when you left and only caught up if you pulled it down by hand. Features now hear about the return — app.js calls f.resumed(awayMs) on focus — and the feed answers it by forcing a pass past the thirty-second throttle and rebuilding its subscriptions, which were talking to sockets that no longer exist. A blink between apps (under five seconds) is left alone. That catch-up raises the second problem rather than solving it: dropping eight posts in at the top moves the words under the thumb of whoever was reading. So posts that arrive on their own — live, or as a catch-up — wait when you're scrolled away from the top. A floating pill says how many, and the tap that lets them in also takes you up to them. Scrolling back to the top by yourself lets them in too, since up there they cost nothing. Anything you actually asked for still goes straight in. Also, on profiles that still show an npub: the batch now falls back to the relays whose whole job is indexing kind 0 (purplepag.es and friends), which we never read notes from and so never asked. profile-batch-test's "handful of asks" bound was a fraction of the row count, which that fallback broke; the property it means to assert is that the number does NOT grow with the rows, so it is a constant now. tools/feed-live-test.js drives a real signed event down a real open subscription — an unsigned fake is dropped by the signature gate and would prove nothing — and checks the page does not move, the pill says "1 new post", tapping it scrolls to the top, and coming back from hidden re-asks the relays on its own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/app.js | 6 ++ src/features/messages.js | 101 +++++++++++++++++++++++--- src/i18n.js | 2 + src/style.css | 21 ++++++ tools/feed-live-test.js | 170 ++++++++++++++++++++++++++++++++++++++++++++ tools/profile-batch-test.js | 16 +++-- 6 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 tools/feed-live-test.js diff --git a/src/app.js b/src/app.js index 69fcf1a..e5bb0ab 100644 --- a/src/app.js +++ b/src/app.js @@ -2051,6 +2051,12 @@ function onAppVisible() { const awayAt = _awayAt(); _clearAwayAt(); if (awayAt) evaluateOverdue(Date.now() - awayAt); armUnlockDeadline(); // background tabs throttle timers — recheck on return + // A phone freezes a backgrounded tab: timers stop, relay sockets are cut, + // and nothing that happened while you were away ever arrives. Features that + // hold live connections get told how long the app was gone so they can + // catch up rather than sit on a dead subscription. + const away = awayAt ? Date.now() - awayAt : 0; + for (const f of FEATURES) { try { f.resumed && f.resumed(away); } catch {} } } // At boot the in-memory timer is gone (reload / discarded tab). Drop overdue diff --git a/src/features/messages.js b/src/features/messages.js index 667c516..69b348b 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -502,6 +502,11 @@ export function messagesFeature(ctx) { // against this wallet's own feed, 131 of the 133 people on screen had a // kind 0 sitting on relays we were already connected to. const PROF_BATCH = 200; + // Relays that exist to index profiles rather than to carry conversation. + // We don't read notes from these, so they never appear in the batch above — + // but they are the ones most likely to hold the kind 0 of someone whose + // own relays we can't reach. + const PROFILE_INDEX_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es', 'wss://relay.nostr.band']; let profQueue = new Set(), profTimer = null, profInFlight = new Set(); function wantProfile(pk) { if (!pk || profQueue.has(pk) || profInFlight.has(pk)) return; @@ -556,9 +561,23 @@ export function messagesFeature(ctx) { } // Whoever our relays have never heard of: ask the relays they publish // to. Same outbox plan the feed uses, so it costs the lists we already - // have rather than a round trip each. + // have rather than a round trip each — plus the relays whose whole job + // is holding kind 0s, which we deliberately don't read notes from and + // so never ask in the batch above. const left = pks.filter((pk) => !found.has(pk)); if (left.length) { + const idx = await queryOn(PROFILE_INDEX_RELAYS, { kinds: [0], authors: left }, 5000).catch(() => []); + const idxNewest = new Map(); + for (const ev of idx || []) { + const c = idxNewest.get(ev.pubkey); + if (!c || ev.created_at > c.created_at) idxNewest.set(ev.pubkey, ev); + } + for (const [pk, ev] of idxNewest) { applyProfile(pk, ev); found.add(pk); } + if (idxNewest.size) scheduleRepaint(); + } + const stillLeft = pks.filter((pk) => !found.has(pk)); + if (stillLeft.length) { + const left = stillLeft; await fetchRelayLists(left); await Promise.all(outboxPlan(left).map(async ({ relays, authors }) => { const evs = await queryOn(relays, { kinds: [0], authors }, 5000).catch(() => []); @@ -2452,6 +2471,8 @@ export function messagesFeature(ctx) { // only runs once a wallet opens. if (typeof window !== 'undefined') { window.addEventListener('scroll', () => { + // back at the top of the feed: the waiting posts belong on screen now + if (ui.chatOpen && ui.msgView === 'feed' && atFeedTop()) flushPending(false); if (!ui.profilePk && !(ui.chatOpen && ui.msgView === 'feed')) return; if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - 600) return; if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {}); @@ -2741,11 +2762,26 @@ export function messagesFeature(ctx) { feedAt = 0; refreshFeed(); } - function mergeFeed(evs) { + // How far from the top counts as "reading", rather than "sitting at the top + // of the feed". Inserting a post above what someone is reading moves the + // words under their eyes; at the top there is nothing to disturb. + const FEED_TOP_PX = 120; + const atFeedTop = () => { + try { return (window.scrollY || 0) < FEED_TOP_PX; } catch { return true; } + }; + + function mergeFeed(evs, opts = {}) { const c = feedNow(); - const seen = new Set(c.notes.map((e) => e.id)); - const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !seen.has(e.id) && seen.add(e.id)); + const known = new Set([...c.notes, ...(c.pending || [])].map((e) => e.id)); + const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey) && !known.has(e.id) && known.add(e.id)); if (!add.length) return false; + // A post that arrived on its own while you were reading waits behind the + // pill instead of shoving the page down. Anything you asked for — a + // refresh, a scroll to the bottom, the first load — goes straight in. + if (opts.live && !atFeedTop() && c.notes.length) { + c.pending = [...(c.pending || []), ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); + return true; + } c.notes = [...c.notes, ...add].sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); // posts arriving at the TOP shouldn't cost you the ones you'd scrolled to const fresh = add.filter((e) => e.created_at >= (c.notes[0] || {}).created_at).length; @@ -2753,10 +2789,30 @@ export function messagesFeature(ctx) { try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} return true; } + + // Let the waiting posts in. Called by the pill, and by simply scrolling + // back to the top — once you are up there they cost nothing to show. + function flushPending(scroll) { + const c = feed; + if (!c || !(c.pending || []).length) return; + const add = c.pending; + c.pending = []; + const seen = new Set(c.notes.map((e) => e.id)); + c.notes = [...c.notes, ...add.filter((e) => !seen.has(e.id))] + .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_KEEP); + c.shown = Math.min((c.shown || FEED_PAGE) + add.length, c.notes.length); + try { wallet.saveFeatureState(FEED_CACHE, c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {} + render(); + if (scroll) { try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); } } + } // One pass over the plan: each relay is asked only for the authors it // actually carries. The slowest relay doesn't hold up the rest — every // answer merges as it lands. - async function feedPass(extra = {}) { + // `merge` rides along to mergeFeed: a catch-up after being away is exactly + // 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. + async function feedPass(extra = {}, merge = {}) { const authors = feedAuthors(); if (!authors.length) return false; await fetchRelayLists(authors); @@ -2767,16 +2823,16 @@ export function messagesFeature(ctx) { 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, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); - if (mergeFeed(evs)) { got = true; scheduleRepaint(); } + if (mergeFeed(evs, merge)) { got = true; scheduleRepaint(); } }); })); return got; } - async function refreshFeed() { + async function refreshFeed(opts = {}) { if (!feedAuthors().length) { if (feed) feed.status = 'ready'; return; } - if (Date.now() - feedAt < 30_000) return; + if (!opts.force && Date.now() - feedAt < 30_000) return; feedAt = Date.now(); - try { await feedPass(); } catch {} finally { + try { await feedPass({}, { live: !!opts.force }); } catch {} finally { if (feed) feed.status = 'ready'; scheduleRepaint(); } @@ -2791,7 +2847,7 @@ export function messagesFeature(ctx) { for (const { relays, authors } of outboxPlan(feedAuthors())) for (let i = 0; i < authors.length; i += REQ_AUTHORS) feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: authors.slice(i, i + REQ_AUTHORS), since }, - (ev) => { if (mergeFeed([ev])) scheduleRepaint(); })); + (ev) => { if (mergeFeed([ev], { live: true })) scheduleRepaint(); })); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } @@ -4082,8 +4138,19 @@ export function messagesFeature(ctx) { i ? h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }) : null, noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ]); + // Posts that arrived while you were reading, waiting to be let in. A + // floating pill rather than an insertion: it says how many, and the tap + // that shows them also takes you up to them. + const waiting = (c.pending || []).length; + const pill = waiting + ? h('button', { + class: 'feed-new-pill', + onClick: () => flushPending(true), + }, '↑ ' + (waiting === 1 ? t('feedOneNew') : t('feedNNew', { n: waiting }))) + : 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' }, + pill, h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { ui.msgView = 'home'; stopFeedWatch(); render(); }), h('h3', { style: 'margin:0' }, t('feedTitle')), @@ -4890,6 +4957,20 @@ export function messagesFeature(ctx) { return { id: 'messages', + // The app came back after being backgrounded. A phone freezes a hidden + // tab: the relay sockets are cut and every post made in the meantime is + // simply missing, which is why the feed used to sit there looking stale + // until you pulled it down by hand. So: re-dial and catch up. The gap is + // fetched (force, past the thirty-second throttle) and the live + // subscriptions are rebuilt, since the ones we had are talking to + // sockets that no longer exist. + resumed(awayMs) { + if (!ui.chatOpen || ui.msgView !== 'feed') return; + // a blink between apps didn't kill anything + if (awayMs && awayMs < 5_000) return; + refreshFeed({ force: true }).catch(() => {}); + return true; + }, // A zap flow reporting how its payment went, so the chip that appeared // on tap can stop pulsing (paid) or disappear (failed / never fired). // Also the way a zap sent from a form reaches the tally right away, diff --git a/src/i18n.js b/src/i18n.js index 5ec5986..bb787b0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -715,6 +715,8 @@ const STR = { arkLnPaidTitle: 'Paid!', or: 'or', nlSignIn: 'Sign in', + feedOneNew: '1 new post', + feedNNew: '{n} new posts', composePreview: 'Preview', composePreviewHide: 'Hide preview', composePreviewTag: 'Preview', diff --git a/src/style.css b/src/style.css index fc25801..8ca9478 100644 --- a/src/style.css +++ b/src/style.css @@ -598,6 +598,27 @@ 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; } +/* 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. */ +.feed-new-pill { + /* The header scrolls away with the page, so this rides at the very top of + the window — floating over a post for the moment it is there beats + sitting in the middle of one. */ + position: fixed; top: calc(env(safe-area-inset-top, 0px) + 10px); + left: 50%; transform: translateX(-50%); + z-index: 30; width: auto; padding: 7px 16px; border-radius: 999px; + background: var(--accent); color: var(--accent-fg); border: none; + font-size: 13px; font-weight: 650; cursor: pointer; + box-shadow: 0 3px 12px rgba(0, 0, 0, .28); + animation: pill-in .18s ease-out; +} +@keyframes pill-in { + from { opacity: 0; transform: translate(-50%, -8px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + /* What a draft will look like once it's out there. Framed and labelled, so it can hold real post content — pictures, players, quoted notes — without ever being mistaken for the post itself. */ diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js new file mode 100644 index 0000000..fd50191 --- /dev/null +++ b/tools/feed-live-test.js @@ -0,0 +1,170 @@ +// Two things about a feed you leave and come back to. +// +// A backgrounded tab is frozen by the phone: timers stop and the relay +// sockets are cut, so every post made while you were away is simply missing +// and the feed sits there looking stale until you pull it down by hand. It +// catches up by itself now. +// +// And a post that lands while you are reading halfway down must not shove the +// page under your thumb. It waits behind a pill that says how many, and the +// tap that shows them takes you up to them. +// +// Run: bun tools/feed-live-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 { 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; }; + +// A real key, because a post arriving on a subscription is signature-checked +// on the way in — an unsigned fake would be dropped and prove nothing. +const SK = generateSecretKey(); +const AUTHOR = getPublicKey(SK); +const signed = (content, ago) => finalizeEvent( + { kind: 1, created_at: Math.floor(Date.now() / 1000) - ago, tags: [], content }, SK); +// enough to scroll past; these ride in through the cache, which is trusted +const seeded = Array.from({ length: 25 }, (_, i) => signed('post number ' + (i + 1), 600 + i * 60)); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5296, 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.__feedReqs = 0; + const subs = []; // [socket, subId] for every open kind-1 subscription + 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' && m[2] && Array.isArray(m[2].kinds) && m[2].kinds.includes(1)) { + window.__feedReqs++; + subs.push([ws, m[1]]); + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); + // Hand a real event to the app exactly as a relay would: down an open + // subscription, through the signature gate, into mergeFeed. + window.__inject = (ev) => { + let n = 0; + for (const [ws, id] of subs) { + if (ws.readyState !== 1) continue; + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); + n++; + } + return n; + }; +}); +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; }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5296/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(500); + await click('import existing'); await sleep(400); + await page.waitForSelector('textarea'); + const mn = generateMnemonic(wordlist); + await page.type('textarea', mn); + await click('open wallet'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + 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)); + }, [base, AUTHOR, seeded]); + 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 sleep(4000); + check('the feed has posts in it', (await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) > 0); + + console.log('\n[a post arriving while you read]'); + // scroll down: this is someone reading, not someone sitting at the top + await page.evaluate(() => window.scrollTo(0, 900)); + await sleep(500); + const before = await page.evaluate(() => ({ + y: Math.round(window.scrollY), + first: (document.querySelector('.notes-feed > .row') || {}).innerText || '', + })); + check('...reading further down', before.y > 400, 'scrollY ' + before.y); + + // a brand new post lands on the live subscription + const fresh = signed('BRAND NEW POST', 0); + const delivered = await page.evaluate((ev) => window.__inject(ev), fresh); + check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)'); + await sleep(900); + const after = await page.evaluate(() => ({ + y: Math.round(window.scrollY), + pill: (document.querySelector('.feed-new-pill') || {}).textContent || '', + inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''), + })); + check('it does not shove itself into the page', !after.inFeed); + check('...it says so in a pill instead', /1 new post/i.test(after.pill), after.pill || 'no pill'); + check('...and the page has not moved under you', Math.abs(after.y - before.y) < 40, + `scrollY ${before.y} → ${after.y}`); + + await page.evaluate(() => document.querySelector('.feed-new-pill').click()); + await sleep(1400); + const opened = await page.evaluate(() => ({ + y: Math.round(window.scrollY), + inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''), + pill: !!document.querySelector('.feed-new-pill'), + })); + check('tapping it shows the post', opened.inFeed); + check('...and takes you up to it', opened.y < 120, 'scrollY ' + opened.y); + check('...and the pill is done', !opened.pill); + + console.log('\n[scrolling back up on your own]'); + await page.evaluate(() => window.scrollTo(0, 900)); + await sleep(400); + await page.evaluate((ev) => window.__inject(ev), signed('ANOTHER ONE', 0)); + await sleep(800); + check('a pill again', !!(await page.$('.feed-new-pill'))); + await page.evaluate(() => window.scrollTo(0, 0)); + await sleep(900); + check('...and scrolling to the top lets it in by itself', + await page.evaluate(() => /ANOTHER ONE/.test(document.querySelector('.notes-feed')?.innerText || '') + && !document.querySelector('.feed-new-pill'))); + + console.log('\n[coming back after being away]'); + // the throttle would otherwise swallow a refresh this soon after the last + // the app was hidden for a while, then focused again + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + // longer than the five seconds that count as a blink between apps + await sleep(6500); + const reqsBefore = await page.evaluate(() => window.__feedReqs || 0); + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await sleep(3500); + const reqsAfter = await page.evaluate(() => window.__feedReqs || 0); + check('coming back re-asks the relays without being told', + reqsAfter > reqsBefore, `${reqsBefore} → ${reqsAfter} kind-1 requests`); + + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the feed keeps up, without moving the page' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/profile-batch-test.js b/tools/profile-batch-test.js index bf8dcf4..8f8250c 100644 --- a/tools/profile-batch-test.js +++ b/tools/profile-batch-test.js @@ -89,13 +89,17 @@ try { const reqs = await page.evaluate(() => window.__k0); const rowCount = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length); - // A screenful of faces must cost a handful of asks, not one per row. (A few - // singles are expected and fine: our own profile, which onboarding reads - // before it writes a name, and the outbox fallback chasing one straggler to - // the relay only they publish to.) + // A screenful of faces must cost a fixed handful of asks — the number must + // not grow with the rows, which is the bug this whole thing is about (one + // REQ per row, eighty at once, most refused). The bound is a constant, not + // a fraction of the rows, because that is the actual property: a couple of + // debounced batches, each of which may also ask the profile-index relays + // and then chase stragglers to their own relays. Singles are expected among + // them — our own profile, and one straggler on a relay only they use. + const ASK_CEILING = 12; const asks = await page.evaluate(() => [...(window.__k0asks || [])].length); - check('a screenful of faces costs a handful of asks, not one per row', - reqs.length > 0 && asks <= Math.max(4, rowCount / 4), + check('a screenful of faces costs a fixed handful of asks, however many rows', + reqs.length > 0 && asks <= ASK_CEILING, `${asks} asks for ${rowCount} rows, batches of ${[...new Set(reqs)].filter((n) => n > 1).join(',')}`); check('...one request covers the screen', Math.max(0, ...reqs) >= 8, 'biggest carried ' + Math.max(0, ...reqs) + ' pubkeys'); -- cgit v1.2.3