diff options
| -rw-r--r-- | src/features/messages.js | 58 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/locales/ar.json | 5 | ||||
| -rw-r--r-- | src/locales/bn.json | 5 | ||||
| -rw-r--r-- | src/locales/de.json | 5 | ||||
| -rw-r--r-- | src/locales/es.json | 5 | ||||
| -rw-r--r-- | src/locales/fr.json | 5 | ||||
| -rw-r--r-- | src/locales/hi.json | 5 | ||||
| -rw-r--r-- | src/locales/id.json | 5 | ||||
| -rw-r--r-- | src/locales/it.json | 5 | ||||
| -rw-r--r-- | src/locales/ja.json | 5 | ||||
| -rw-r--r-- | src/locales/ko.json | 5 | ||||
| -rw-r--r-- | src/locales/mr.json | 5 | ||||
| -rw-r--r-- | src/locales/pt.json | 5 | ||||
| -rw-r--r-- | src/locales/ru.json | 5 | ||||
| -rw-r--r-- | src/locales/sw.json | 5 | ||||
| -rw-r--r-- | src/locales/ta.json | 5 | ||||
| -rw-r--r-- | src/locales/te.json | 5 | ||||
| -rw-r--r-- | src/locales/tr.json | 5 | ||||
| -rw-r--r-- | src/locales/ur.json | 5 | ||||
| -rw-r--r-- | src/locales/vi.json | 5 | ||||
| -rw-r--r-- | src/locales/zh.json | 5 | ||||
| -rw-r--r-- | src/style.css | 54 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 2 | ||||
| -rw-r--r-- | tools/feed-pager-ahead-test.js | 22 | ||||
| -rw-r--r-- | tools/pager-tail-test.js | 144 |
26 files changed, 345 insertions, 38 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 61cdc18..6e977af 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -11994,7 +11994,7 @@ export function messagesFeature(ctx) { function dropPager() { const p = pager; pager = null; - try { p.io.disconnect(); p.near.disconnect(); } catch {} + try { p.io.disconnect(); p.near.disconnect(); p.tailIo.disconnect(); } catch {} clearInterval(p.tick); cancelAnimationFrame(p.raf); window.removeEventListener('resize', p.onResize); @@ -12114,6 +12114,8 @@ export function messagesFeature(ctx) { }; return bar; } + // the pager's slides, without the loading one at the end + const pagerSlides = () => (pager ? [...pager.scroller.children].filter((x) => !x._tail) : []); function pagerAttach(slide) { const m = slide.querySelector('.mp-media'); if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src')); @@ -12136,7 +12138,7 @@ export function messagesFeature(ctx) { // clip holds nothing. function pagerNeighbours(slide) { if (!pager || pager.kind !== 'videos') return; - const slides = [...pager.scroller.children]; + const slides = pagerSlides(); const i = slides.indexOf(slide); const eager = !frugalData(); slides.forEach((s, j) => { @@ -12265,7 +12267,7 @@ export function messagesFeature(ctx) { h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)); // a tap shows (or hides) who posted it and what they said el.addEventListener('click', (e) => { - if (e.target.closest('button, a, .mp-caption, .mp-seek')) return; + if (e.target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it if (pager && pager.fromFeed && video) { // A clip opened from the feed: a tap pauses it and brings up who @@ -12291,6 +12293,32 @@ export function messagesFeature(ctx) { if (el.classList.contains('show')) pagerCounts(pager && pager.cur); }); pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start, at: want.at || 0 }; + // The end of the pager: swiping past the last slide lands here, on + // something saying more is coming (not a nudge that goes nowhere) — and + // asks for it at once. Slides that arrive go in above it; one arriving + // while the reader waits here is moved onto, and plays. With nothing + // more to be had, it says so. + const tail = h('div', { class: 'mp-tail', 'aria-live': 'polite' }, + h('div', { class: 'mp-loader', 'aria-hidden': 'true', html: video + ? '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="30" height="30"><path d="M8 5.5v13l10.5-6.5z" fill="currentColor"/></svg></span>' + : '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M3.5 16l5-5 4 4 2.5-2.5 5 5"/><circle cx="15.5" cy="9" r="1.6" fill="currentColor" stroke="none"/></svg></span>' }), + h('div', { class: 'mp-tail-text' }, t(video ? 'pagerFetchingVideo' : 'pagerFetchingPictures'))); + tail._tail = true; + scroller.append(tail); + pager.tail = tail; + pager.tailIo = new IntersectionObserver((entries) => { + for (const e of entries) { + if (!pager) return; + pager.onTail = e.isIntersecting && e.intersectionRatio >= 0.6; + if (!pager.onTail) continue; + const v = pager.cur && pager.cur.querySelector('video.mp-media'); + if (v) v.pause(); + el.classList.remove('show'); + if (pager.fromFeed) pagerFeedAhead(); else crawlMedia().catch(() => {}); + pagerTailState(); + } + }, { root: scroller, threshold: [0, 0.6] }); + pager.tailIo.observe(tail); pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -12313,13 +12341,13 @@ export function messagesFeature(ctx) { pagerCounts(e.target); // near the end: ask the feed for more, the pager picks them up if (pager.fromFeed) pagerFeedAhead(); - else { const slides = scroller.children; if ([...slides].indexOf(e.target) >= slides.length - 3) crawlMedia().catch(() => {}); } + else { const slides = pagerSlides(); if (slides.indexOf(e.target) >= slides.length - 3) crawlMedia().catch(() => {}); } } }, { root: scroller, threshold: [0.6] }); growPager(); document.body.append(el); document.documentElement.classList.add('no-scroll'); - const first = [...scroller.children].find((s) => s.getAttribute('data-k') === want.start) || scroller.firstChild; + const first = pagerSlides().find((s) => s.getAttribute('data-k') === want.start) || scroller.firstChild; if (first) scroller.scrollTop = first.offsetTop; // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) if (video) { @@ -12349,7 +12377,7 @@ export function messagesFeature(ctx) { try { const c = feedNow(); for (let pages = 0; pages < 8 && pager && pager.fromFeed; pages++) { - const slides = [...pager.scroller.children]; + const slides = pagerSlides(); if (slides.length - 1 - slides.indexOf(pager.cur) > (PAGER_AHEAD[pager.kind] || 4)) break; const shown = c.shown, held = c.notes.length; await loadOlderFeed(); @@ -12363,15 +12391,31 @@ export function messagesFeature(ctx) { function growPager() { if (!pager) return; const c = feedNow(); + const waiting = pager.onTail; + let first = null; for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); const s = pagerSlide(it); - pager.scroller.append(s); + pager.scroller.insertBefore(s, pager.tail || null); + first ||= s; pager.near.observe(s); pager.io.observe(s); } + // the reader was waiting at the end: onto the first that came + if (waiting && first) { pager.onTail = false; pager.scroller.scrollTop = first.offsetTop; } + pagerTailState(); + } + // the end says "more coming", or — the feed (or the media index) has no + // more to give — that this is everything + function pagerTailState() { + if (!pager || !pager.tail) return; + const c = feedNow(); + const done = pager.fromFeed ? !!c.end && c.shown >= c.notes.length && !c.loadingMore : !!mediaIndex(c.id).end; + if (pager.tail.classList.contains('done') === done) return; + pager.tail.classList.toggle('done', done); + pager.tail.querySelector('.mp-tail-text').textContent = done ? t('pagerCaughtUp') : t(pager.kind === 'videos' ? 'pagerFetchingVideo' : 'pagerFetchingPictures'); } if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { diff --git a/src/i18n.js b/src/i18n.js index 8d014a6..82004a2 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -271,6 +271,9 @@ const STR = { playVideo: 'Play', videoUnmute: 'Unmute', videoTapToPlay: "Tap to play", + pagerFetchingVideo: "Fetching the next video…", + pagerFetchingPictures: "Fetching more pictures…", + pagerCaughtUp: "That’s everything for now", videoNoPicture: "This browser can’t show this video’s picture", videoOpenFile: "Open the file", videoLargeTitle: "Videos", diff --git a/src/locales/ar.json b/src/locales/ar.json index bf7bcc6..f112568 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "المتبقي للإنفاق في الأثناء", "arkRenewConfirmAll": "هذا كامل رصيد الإنفاق لديك. لن تتمكن من الدفع أو الإرسال منه حتى يتأكد التجديد.", "arkRenewConfirmWait": "العملات قيد التجديد لا يمكن إنفاقها حتى تتأكد معاملة الجولة على شبكة بيتكوين: عادة نحو عشر دقائق، وأحيانًا ساعة أو أكثر. لا يمكن إلغاء التجديد بعد بدئه.", - "arkRenewViewTx": "عرض معاملة الجولة" + "arkRenewViewTx": "عرض معاملة الجولة", + "pagerFetchingVideo": "جارٍ جلب الفيديو التالي…", + "pagerFetchingPictures": "جارٍ جلب المزيد من الصور…", + "pagerCaughtUp": "هذا كل شيء الآن" } diff --git a/src/locales/bn.json b/src/locales/bn.json index 2689e34..85ce58a 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "এর মধ্যে খরচের জন্য থাকবে", "arkRenewConfirmAll": "এটি আপনার পুরো খরচের ব্যালেন্স। নবায়ন নিশ্চিত না হওয়া পর্যন্ত এখান থেকে পেমেন্ট বা পাঠানো যাবে না।", "arkRenewConfirmWait": "নবায়নরত কয়েন রাউন্ডের লেনদেন Bitcoin নেটওয়ার্কে নিশ্চিত না হওয়া পর্যন্ত খরচ করা যায় না: সাধারণত প্রায় দশ মিনিট, কখনও এক ঘণ্টা বা তার বেশি। শুরু হওয়া নবায়ন বাতিল করা যায় না।", - "arkRenewViewTx": "রাউন্ডের লেনদেন দেখুন" + "arkRenewViewTx": "রাউন্ডের লেনদেন দেখুন", + "pagerFetchingVideo": "পরের ভিডিও আনা হচ্ছে…", + "pagerFetchingPictures": "আরও ছবি আনা হচ্ছে…", + "pagerCaughtUp": "আপাতত এটুকুই" } diff --git a/src/locales/de.json b/src/locales/de.json index eb64335..0210332 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "In der Zwischenzeit verfügbar", "arkRenewConfirmAll": "Das ist dein gesamtes Ausgabenguthaben. Du kannst daraus nichts bezahlen oder senden, bis die Erneuerung bestätigt ist.", "arkRenewConfirmWait": "Coins in Erneuerung können erst ausgegeben werden, wenn die Transaktion der Runde im Bitcoin-Netzwerk bestätigt ist: meist etwa zehn Minuten, manchmal eine Stunde oder länger. Eine begonnene Erneuerung lässt sich nicht abbrechen.", - "arkRenewViewTx": "Transaktion der Runde ansehen" + "arkRenewViewTx": "Transaktion der Runde ansehen", + "pagerFetchingVideo": "Nächstes Video wird geladen …", + "pagerFetchingPictures": "Weitere Bilder werden geladen …", + "pagerCaughtUp": "Das ist vorerst alles" } diff --git a/src/locales/es.json b/src/locales/es.json index 42d3e0a..9637b0a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "Queda para gastar mientras tanto", "arkRenewConfirmAll": "Es todo tu saldo de Gastos. No podrás pagar ni enviar desde Gastos hasta que la renovación se confirme.", "arkRenewConfirmWait": "Las monedas en renovación no pueden gastarse hasta que la transacción de la ronda se confirme en la red Bitcoin: normalmente unos diez minutos, a veces una hora o más. Una renovación iniciada no se puede cancelar.", - "arkRenewViewTx": "Ver la transacción de la ronda" + "arkRenewViewTx": "Ver la transacción de la ronda", + "pagerFetchingVideo": "Cargando el siguiente vídeo…", + "pagerFetchingPictures": "Cargando más imágenes…", + "pagerCaughtUp": "Eso es todo por ahora" } diff --git a/src/locales/fr.json b/src/locales/fr.json index 2ebc60c..280be04 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "Reste à dépenser entre-temps", "arkRenewConfirmAll": "C’est tout votre solde Dépenses. Vous ne pourrez ni payer ni envoyer depuis Dépenses avant la confirmation du renouvellement.", "arkRenewConfirmWait": "Les pièces en cours de renouvellement ne peuvent pas être dépensées avant que la transaction du tour soit confirmée sur le réseau Bitcoin : en général une dizaine de minutes, parfois une heure ou plus. Un renouvellement lancé ne peut pas être annulé.", - "arkRenewViewTx": "Voir la transaction du tour" + "arkRenewViewTx": "Voir la transaction du tour", + "pagerFetchingVideo": "Chargement de la vidéo suivante…", + "pagerFetchingPictures": "Chargement d’autres images…", + "pagerCaughtUp": "C’est tout pour le moment" } diff --git a/src/locales/hi.json b/src/locales/hi.json index fdaa332..8057344 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "इस बीच खर्च के लिए बचेगा", "arkRenewConfirmAll": "यह आपका पूरा खर्च बैलेंस है। नवीनीकरण की पुष्टि होने तक आप इससे भुगतान या भेज नहीं पाएँगे।", "arkRenewConfirmWait": "जिन सिक्कों का नवीनीकरण हो रहा है, उन्हें तब तक खर्च नहीं किया जा सकता जब तक राउंड का लेनदेन Bitcoin नेटवर्क पर पुष्ट न हो: आमतौर पर लगभग दस मिनट, कभी-कभी एक घंटा या ज़्यादा। शुरू हुआ नवीनीकरण रद्द नहीं किया जा सकता।", - "arkRenewViewTx": "राउंड का लेनदेन देखें" + "arkRenewViewTx": "राउंड का लेनदेन देखें", + "pagerFetchingVideo": "अगला वीडियो लाया जा रहा है…", + "pagerFetchingPictures": "और तस्वीरें लाई जा रही हैं…", + "pagerCaughtUp": "अभी के लिए बस इतना ही" } diff --git a/src/locales/id.json b/src/locales/id.json index ca050a9..6773d96 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -258,5 +258,8 @@ "arkRenewConfirmLeft": "Sisa untuk dibelanjakan sementara itu", "arkRenewConfirmAll": "Ini seluruh saldo Belanja-mu. Kamu tidak bisa membayar atau mengirim dari Belanja sampai pembaruan terkonfirmasi.", "arkRenewConfirmWait": "Koin yang sedang diperbarui tidak bisa dibelanjakan sampai transaksi ronde terkonfirmasi di jaringan Bitcoin: biasanya sekitar sepuluh menit, kadang satu jam atau lebih. Pembaruan yang sudah dimulai tidak bisa dibatalkan.", - "arkRenewViewTx": "Lihat transaksi ronde" + "arkRenewViewTx": "Lihat transaksi ronde", + "pagerFetchingVideo": "Mengambil video berikutnya…", + "pagerFetchingPictures": "Mengambil gambar lainnya…", + "pagerCaughtUp": "Sementara itu saja" } diff --git a/src/locales/it.json b/src/locales/it.json index 11ecd10..7ac8b00 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1455,5 +1455,8 @@ "passkeyLockedDesc": "I tuoi portafogli sono bloccati su questo dispositivo. Usa l’impronta o il volto per aprirli.", "passkeyEnterDesc": "Usa l’impronta o il volto per aprire i portafogli salvati su questo dispositivo.", "passkeyLostHint": "Hai perso la passkey? Le parole di recupero ripristinano comunque ogni portafoglio.", - "lockPwOrPassword": "oppure imposta una password:" + "lockPwOrPassword": "oppure imposta una password:", + "pagerFetchingVideo": "Caricamento del prossimo video…", + "pagerFetchingPictures": "Caricamento di altre immagini…", + "pagerCaughtUp": "Per ora è tutto" } diff --git a/src/locales/ja.json b/src/locales/ja.json index b8d9fb9..b8968cc 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "その間に使える残高", "arkRenewConfirmAll": "これは支払い用残高のすべてです。更新が承認されるまで、支払いも送金もできません。", "arkRenewConfirmWait": "更新中のコインは、ラウンドのトランザクションがビットコインネットワークで承認されるまで使えません(通常は約10分、1時間以上かかることもあります)。始めた更新は取り消せません。", - "arkRenewViewTx": "ラウンドのトランザクションを見る" + "arkRenewViewTx": "ラウンドのトランザクションを見る", + "pagerFetchingVideo": "次の動画を取得中…", + "pagerFetchingPictures": "画像をさらに取得中…", + "pagerCaughtUp": "今のところ以上です" } diff --git a/src/locales/ko.json b/src/locales/ko.json index b81a617..e4bf342 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -920,5 +920,8 @@ "arkRenewConfirmLeft": "그동안 쓸 수 있는 잔액", "arkRenewConfirmAll": "이것은 지출 잔액 전부입니다. 갱신이 확인될 때까지 지출 잔액으로 결제하거나 보낼 수 없습니다.", "arkRenewConfirmWait": "갱신 중인 코인은 라운드 거래가 비트코인 네트워크에서 확인될 때까지 쓸 수 없습니다. 보통 10분 정도, 때로는 1시간 이상 걸립니다. 시작한 갱신은 취소할 수 없습니다.", - "arkRenewViewTx": "라운드 거래 보기" + "arkRenewViewTx": "라운드 거래 보기", + "pagerFetchingVideo": "다음 동영상을 가져오는 중…", + "pagerFetchingPictures": "사진을 더 가져오는 중…", + "pagerCaughtUp": "지금은 여기까지예요" } diff --git a/src/locales/mr.json b/src/locales/mr.json index ef64569..17ee1d5 100644 --- a/src/locales/mr.json +++ b/src/locales/mr.json @@ -258,5 +258,8 @@ "arkRenewConfirmLeft": "तोपर्यंत खर्चासाठी उरलेले", "arkRenewConfirmAll": "ही तुमची संपूर्ण खर्च शिल्लक आहे. नूतनीकरण निश्चित होईपर्यंत तुम्ही यातून पैसे देऊ किंवा पाठवू शकणार नाही.", "arkRenewConfirmWait": "नूतनीकरण होत असलेली नाणी, राउंडचा व्यवहार Bitcoin नेटवर्कवर निश्चित होईपर्यंत खर्च करता येत नाहीत: साधारणपणे सुमारे दहा मिनिटे, कधी कधी तासभर किंवा जास्त. सुरू झालेले नूतनीकरण रद्द करता येत नाही.", - "arkRenewViewTx": "राउंडचा व्यवहार पहा" + "arkRenewViewTx": "राउंडचा व्यवहार पहा", + "pagerFetchingVideo": "पुढचा व्हिडिओ आणत आहे…", + "pagerFetchingPictures": "आणखी चित्रे आणत आहे…", + "pagerCaughtUp": "सध्या एवढेच" } diff --git a/src/locales/pt.json b/src/locales/pt.json index ceb6c36..11ff22c 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "Sobra para gastar enquanto isso", "arkRenewConfirmAll": "Este é todo o seu saldo de Gastos. Você não poderá pagar nem enviar de Gastos até a renovação ser confirmada.", "arkRenewConfirmWait": "Moedas em renovação não podem ser gastas até a transação da rodada ser confirmada na rede Bitcoin: normalmente uns dez minutos, às vezes uma hora ou mais. Uma renovação iniciada não pode ser cancelada.", - "arkRenewViewTx": "Ver a transação da rodada" + "arkRenewViewTx": "Ver a transação da rodada", + "pagerFetchingVideo": "Buscando o próximo vídeo…", + "pagerFetchingPictures": "Buscando mais imagens…", + "pagerCaughtUp": "Por enquanto é só isso" } diff --git a/src/locales/ru.json b/src/locales/ru.json index 72f1d1b..0f2936d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "Останется для трат на это время", "arkRenewConfirmAll": "Это весь ваш баланс для трат. Платить и отправлять с него будет нельзя, пока обновление не подтвердится.", "arkRenewConfirmWait": "Монеты в процессе обновления нельзя потратить, пока транзакция раунда не подтвердится в сети Bitcoin: обычно около десяти минут, иногда час и больше. Начатое обновление нельзя отменить.", - "arkRenewViewTx": "Посмотреть транзакцию раунда" + "arkRenewViewTx": "Посмотреть транзакцию раунда", + "pagerFetchingVideo": "Загружаем следующее видео…", + "pagerFetchingPictures": "Загружаем ещё картинки…", + "pagerCaughtUp": "Пока это всё" } diff --git a/src/locales/sw.json b/src/locales/sw.json index bd2f671..b130faf 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -258,5 +258,8 @@ "arkRenewConfirmLeft": "Kinachobaki kutumia kwa sasa", "arkRenewConfirmAll": "Hili ni salio lako lote la Matumizi. Hutaweza kulipa wala kutuma kutoka Matumizi hadi uhuishaji uthibitishwe.", "arkRenewConfirmWait": "Sarafu zinazohuishwa haziwezi kutumika hadi muamala wa raundi uthibitishwe kwenye mtandao wa Bitcoin: kwa kawaida kama dakika kumi, wakati mwingine saa moja au zaidi. Uhuishaji ukishaanza hauwezi kughairiwa.", - "arkRenewViewTx": "Tazama muamala wa raundi" + "arkRenewViewTx": "Tazama muamala wa raundi", + "pagerFetchingVideo": "Inaleta video inayofuata…", + "pagerFetchingPictures": "Inaleta picha zaidi…", + "pagerCaughtUp": "Hayo ndiyo yote kwa sasa" } diff --git a/src/locales/ta.json b/src/locales/ta.json index 8806d79..da4e5b0 100644 --- a/src/locales/ta.json +++ b/src/locales/ta.json @@ -258,5 +258,8 @@ "arkRenewConfirmLeft": "அதுவரை செலவிட மீதம்", "arkRenewConfirmAll": "இது உங்கள் செலவு இருப்பு முழுவதும். புதுப்பிப்பு உறுதியாகும் வரை இதிலிருந்து செலுத்தவோ அனுப்பவோ முடியாது.", "arkRenewConfirmWait": "புதுப்பிக்கப்படும் நாணயங்களை, சுற்றின் பரிவர்த்தனை Bitcoin வலையமைப்பில் உறுதியாகும் வரை செலவிட முடியாது: பொதுவாக சுமார் பத்து நிமிடங்கள், சில நேரங்களில் ஒரு மணி நேரம் அல்லது அதற்கு மேல். தொடங்கிய புதுப்பிப்பை ரத்து செய்ய முடியாது.", - "arkRenewViewTx": "சுற்றின் பரிவர்த்தனையைக் காண்க" + "arkRenewViewTx": "சுற்றின் பரிவர்த்தனையைக் காண்க", + "pagerFetchingVideo": "அடுத்த வீடியோவைப் பெறுகிறது…", + "pagerFetchingPictures": "மேலும் படங்களைப் பெறுகிறது…", + "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்" } diff --git a/src/locales/te.json b/src/locales/te.json index a95ae6b..7e60eaf 100644 --- a/src/locales/te.json +++ b/src/locales/te.json @@ -256,5 +256,8 @@ "arkRenewConfirmLeft": "ఈలోగా ఖర్చుకు మిగిలేది", "arkRenewConfirmAll": "ఇది మీ మొత్తం ఖర్చు నిల్వ. పునరుద్ధరణ నిర్ధారణ అయ్యే వరకు దీని నుంచి చెల్లించలేరు, పంపలేరు.", "arkRenewConfirmWait": "పునరుద్ధరణలో ఉన్న నాణేలను, రౌండ్ లావాదేవీ Bitcoin నెట్వర్క్లో నిర్ధారణ అయ్యే వరకు ఖర్చు చేయలేరు: సాధారణంగా సుమారు పది నిమిషాలు, కొన్నిసార్లు గంట లేదా అంతకంటే ఎక్కువ. మొదలైన పునరుద్ధరణను రద్దు చేయలేరు.", - "arkRenewViewTx": "రౌండ్ లావాదేవీని చూడండి" + "arkRenewViewTx": "రౌండ్ లావాదేవీని చూడండి", + "pagerFetchingVideo": "తదుపరి వీడియోను తెస్తోంది…", + "pagerFetchingPictures": "మరిన్ని చిత్రాలను తెస్తోంది…", + "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే" } diff --git a/src/locales/tr.json b/src/locales/tr.json index e0d87c6..1e60eeb 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -920,5 +920,8 @@ "arkRenewConfirmLeft": "Bu sırada harcanabilir kalan", "arkRenewConfirmAll": "Bu, Harcama bakiyenin tamamı. Yenileme onaylanana kadar Harcama’dan ödeme yapamaz veya gönderemezsin.", "arkRenewConfirmWait": "Yenilenen coinler, turun işlemi Bitcoin ağında onaylanana kadar harcanamaz: genellikle on dakika kadar, bazen bir saat veya daha fazla. Başlayan bir yenileme iptal edilemez.", - "arkRenewViewTx": "Turun işlemini görüntüle" + "arkRenewViewTx": "Turun işlemini görüntüle", + "pagerFetchingVideo": "Sonraki video getiriliyor…", + "pagerFetchingPictures": "Daha fazla resim getiriliyor…", + "pagerCaughtUp": "Şimdilik bu kadar" } diff --git a/src/locales/ur.json b/src/locales/ur.json index 0b0639d..6efb6ec 100644 --- a/src/locales/ur.json +++ b/src/locales/ur.json @@ -258,5 +258,8 @@ "arkRenewConfirmLeft": "اس دوران خرچ کے لیے باقی", "arkRenewConfirmAll": "یہ آپ کا پورا خرچ بیلنس ہے۔ تجدید کی تصدیق تک آپ اس سے ادائیگی یا بھیج نہیں سکیں گے۔", "arkRenewConfirmWait": "جن سکوں کی تجدید ہو رہی ہے وہ اس وقت تک خرچ نہیں ہو سکتے جب تک راؤنڈ کی ٹرانزیکشن Bitcoin نیٹ ورک پر تصدیق نہ ہو جائے: عام طور پر تقریباً دس منٹ، کبھی کبھی ایک گھنٹہ یا زیادہ۔ شروع ہونے والی تجدید منسوخ نہیں ہو سکتی۔", - "arkRenewViewTx": "راؤنڈ کی ٹرانزیکشن دیکھیں" + "arkRenewViewTx": "راؤنڈ کی ٹرانزیکشن دیکھیں", + "pagerFetchingVideo": "اگلی ویڈیو لائی جا رہی ہے…", + "pagerFetchingPictures": "مزید تصاویر لائی جا رہی ہیں…", + "pagerCaughtUp": "فی الحال بس اتنا ہی" } diff --git a/src/locales/vi.json b/src/locales/vi.json index 379b759..415f4e7 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -258,5 +258,8 @@ "arkRenewConfirmLeft": "Còn lại để chi tiêu trong lúc đó", "arkRenewConfirmAll": "Đây là toàn bộ số dư Chi tiêu của bạn. Bạn sẽ không thể thanh toán hay gửi từ Chi tiêu cho đến khi việc gia hạn được xác nhận.", "arkRenewConfirmWait": "Coin đang gia hạn không thể chi tiêu cho đến khi giao dịch của vòng được xác nhận trên mạng Bitcoin: thường khoảng mười phút, đôi khi một giờ hoặc hơn. Việc gia hạn đã bắt đầu thì không thể hủy.", - "arkRenewViewTx": "Xem giao dịch của vòng" + "arkRenewViewTx": "Xem giao dịch của vòng", + "pagerFetchingVideo": "Đang tải video tiếp theo…", + "pagerFetchingPictures": "Đang tải thêm ảnh…", + "pagerCaughtUp": "Tạm thời chỉ có vậy" } diff --git a/src/locales/zh.json b/src/locales/zh.json index 9493ab8..6b3cc49 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -921,5 +921,8 @@ "arkRenewConfirmLeft": "期间可用余额", "arkRenewConfirmAll": "这是你全部的消费余额。在续期确认之前,你无法从消费余额付款或转账。", "arkRenewConfirmWait": "续期中的币在该轮交易于比特币网络确认之前无法使用:通常约十分钟,有时一小时或更久。续期一旦开始便无法取消。", - "arkRenewViewTx": "查看该轮交易" + "arkRenewViewTx": "查看该轮交易", + "pagerFetchingVideo": "正在获取下一个视频…", + "pagerFetchingPictures": "正在获取更多图片…", + "pagerCaughtUp": "暂时就这些了" } diff --git a/src/style.css b/src/style.css index 448f702..e66d756 100644 --- a/src/style.css +++ b/src/style.css @@ -1472,6 +1472,8 @@ body::after { .mp-scroll { position: absolute; inset: 0; overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scrollbar-width: none; + /* slides go in above the loading one at the end: no anchoring to hold it in view */ + overflow-anchor: none; } .mp-scroll::-webkit-scrollbar { display: none; } .mp-slide { @@ -1528,6 +1530,58 @@ body::after { .media-pager.quiet .mp-top .mp-x { opacity: 0; pointer-events: none; } .media-pager.quiet .mp-top .mp-sound { pointer-events: auto; } .media-pager.quiet.show .mp-top .mp-x { opacity: 1; pointer-events: auto; } +/* the end of the pager, while more is fetched: a ring sweeping round, three + sparks orbiting the other way, the play mark breathing in the middle, the + words with a sheen running through them. Caught up, it all goes still. */ +.mp-tail { + position: relative; height: 100vh; height: 100dvh; width: 100%; box-sizing: border-box; + scroll-snap-align: start; scroll-snap-stop: always; overflow: hidden; + display: flex; align-items: center; justify-content: center; + flex-direction: column; gap: 26px; text-align: center; padding: 0 32px; + background: radial-gradient(circle at 50% 46%, rgba(247, 147, 26, .16), rgba(124, 92, 255, .06) 38%, transparent 62%); +} +.mp-loader { position: relative; width: 116px; height: 116px; display: block; } +.mp-loader::before { + content: ''; position: absolute; inset: 0; border-radius: 50%; + background: conic-gradient(from 0deg, transparent 0 50%, rgba(247, 147, 26, .2) 58%, #f7931a 76%, #ff4d6d 88%, #a78bfa 100%); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3px)); + animation: mp-spin 1.15s linear infinite; +} +.mp-orbit { position: absolute; inset: -14px; display: block; animation: mp-spin 3.2s linear infinite reverse; } +.mp-orbit i { + position: absolute; top: 50%; left: 50%; width: 8px; height: 8px; margin: -4px; border-radius: 50%; + animation: mp-spark 1.6s ease-in-out infinite; +} +.mp-orbit i:nth-child(1) { transform: rotate(0deg) translateX(72px); background: #f7931a; box-shadow: 0 0 12px 2px rgba(247, 147, 26, .8); } +.mp-orbit i:nth-child(2) { transform: rotate(120deg) translateX(72px); background: #ff4d6d; box-shadow: 0 0 12px 2px rgba(255, 77, 109, .8); animation-delay: -.53s; } +.mp-orbit i:nth-child(3) { transform: rotate(240deg) translateX(72px); background: #a78bfa; box-shadow: 0 0 12px 2px rgba(167, 139, 250, .8); animation-delay: -1.06s; } +.mp-core { + position: absolute; inset: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; + color: #fff; background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, .22), rgba(255, 255, 255, .06)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .12); + animation: mp-breathe 1.5s ease-in-out infinite; +} +.mp-core svg { margin-left: 3px; } +.mp-tail-text { + font-size: 16px; font-weight: 500; letter-spacing: .01em; color: rgba(255, 255, 255, .7); + background: linear-gradient(100deg, rgba(255, 255, 255, .55) 30%, #fff 50%, rgba(255, 255, 255, .55) 70%); + background-size: 250% 100%; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; + animation: mp-sheen 2s linear infinite; +} +@keyframes mp-spin { to { transform: rotate(360deg); } } +@keyframes mp-breathe { 0%, 100% { transform: scale(.9); opacity: .7; } 50% { transform: scale(1); opacity: 1; } } +@keyframes mp-spark { 0%, 100% { opacity: .35; } 50% { opacity: 1; } } +@keyframes mp-sheen { from { background-position: 125% 0; } to { background-position: -125% 0; } } +.mp-tail.done { background: none; } +.mp-tail.done .mp-loader::before, .mp-tail.done .mp-orbit, .mp-tail.done .mp-orbit i, .mp-tail.done .mp-core, .mp-tail.done .mp-tail-text { animation: none; } +.mp-tail.done .mp-loader { opacity: .3; } +.mp-tail.done .mp-tail-text { background: none; -webkit-text-fill-color: currentColor; color: rgba(255, 255, 255, .8); } +@media (prefers-reduced-motion: reduce) { + .mp-loader::before, .mp-orbit, .mp-orbit i, .mp-core, .mp-tail-text { animation: none; } + .mp-tail-text { background: none; -webkit-text-fill-color: currentColor; } +} + /* a clip's seek bar: a thin line along the bottom, thicker with the details up or under a finger; the strip above it is the finger's (no swipe there). Lifted clear of the phone's own gesture strip along the bottom edge (which diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index 7f234a9..14d766e 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -95,7 +95,7 @@ const pagerState = () => page.evaluate(() => { const p = document.querySelector('.media-pager'); if (!p) return null; const sc = p.querySelector('.mp-scroll'); - const slides = [...sc.children]; + const slides = [...sc.querySelectorAll(':scope > .mp-slide')]; // (not the loading screen at the end) const i = Math.round(sc.scrollTop / sc.clientHeight); const vids = slides.map((s) => s.querySelector('video')); return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(), diff --git a/tools/feed-pager-ahead-test.js b/tools/feed-pager-ahead-test.js index 504c122..90cbe96 100644 --- a/tools/feed-pager-ahead-test.js +++ b/tools/feed-pager-ahead-test.js @@ -70,10 +70,10 @@ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i // the slide the pager rests on, by its picture const curPic = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; - const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); + const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; }); -const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); const openPic = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word); await sleep(400); @@ -82,12 +82,12 @@ const openPic = async (word) => { await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); return at.y0; }; -const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); -const slides = () => page.evaluate(() => document.querySelector('.mp-scroll')?.children.length || 0); -const curIdx = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return -1; return [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); }); -const curName = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = sc && [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop() : null; }); +const slides = () => page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-slide').length); +const curIdx = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return -1; return [...sc.querySelectorAll(':scope > .mp-slide')].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); }); +const curName = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = sc && [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop() : null; }); try { await page.setViewport({ width: 390, height: 844 }); @@ -128,21 +128,23 @@ try { maxSlides = Math.max(maxSlides, n); if (i >= 0 && n - 1 - i <= 4) { // give the look-ahead a moment: it should get past three ahead again (or the feed's end) - await waitFor((k) => { const sc = document.querySelector('.mp-scroll'); const j = [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return sc.children.length - 1 - j > 4 || sc.children.length >= k; }, TOTAL, 6000); + await waitFor((k) => { const sc = document.querySelector('.mp-scroll'); const j = [...sc.querySelectorAll(':scope > .mp-slide')].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return sc.querySelectorAll(':scope > .mp-slide').length - 1 - j > 4 || sc.querySelectorAll(':scope > .mp-slide').length >= k; }, TOTAL, 6000); } const before = await curIdx(); await page.keyboard.press('ArrowDown'); - await waitFor((b) => { const sc = document.querySelector('.mp-scroll'); return [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) > b; }, before, 2000); + await waitFor((b) => { const sc = document.querySelector('.mp-scroll'); return [...sc.querySelectorAll(':scope > .mp-slide')].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) > b; }, before, 2000); } + // past the last picture is the pager's end (its loading screen): back up onto the last + if (await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const t = sc.querySelector('.mp-tail'); return t && Math.abs(t.offsetTop - sc.scrollTop) < 4; })) { await page.keyboard.press('ArrowUp'); await sleep(800); } grew = (await slides()) > first; check('the pager grew as the end came near', grew, first + ' → ' + (await slides())); check('every picture in the feed became reachable', (await slides()) === TOTAL, (await slides()) + ' of ' + TOTAL); check('…past fifteen posts with none', (await curName()) === 'p' + (TOTAL - 1) + '.png', await curName()); - check('…in the feed\'s order', await page.evaluate((t) => [...document.querySelector('.mp-scroll').children].every((s, i) => s.getAttribute('data-k').endsWith('/p' + i + '.png')), TOTAL)); + check('…in the feed\'s order', await page.evaluate((t) => [...document.querySelectorAll('.mp-scroll > .mp-slide')].every((s, i) => s.getAttribute('data-k').endsWith('/p' + i + '.png')), TOTAL)); console.log('\n[closing lands the feed on the last picture\'s post]'); await page.keyboard.press('ArrowUp'); - await waitFor((n) => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s && s.getAttribute('data-k').endsWith('/p' + n + '.png'); }, TOTAL - 2, 3000); + await waitFor((n) => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll(':scope > .mp-slide')].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s && s.getAttribute('data-k').endsWith('/p' + n + '.png'); }, TOTAL - 2, 3000); const want = 'picture number ' + (TOTAL - 2) + ' '; await page.keyboard.press('Escape'); check('the pager closes', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js new file mode 100644 index 0000000..c9d26cc --- /dev/null +++ b/tools/pager-tail-test.js @@ -0,0 +1,144 @@ +// The end of the pager: swiping past the last clip (when the next isn't in +// yet) lands on a "fetching the next video" screen, not a nudge that goes +// nowhere; the clip left behind stops; the next clip, when it arrives, is +// moved onto and plays by itself. With nothing older to be had, the end says +// that's everything. Escape from there lands on the last clip's post. +// Run: bun tools/pager-tail-test.js (SHOT=path.png saves the loading screen) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const CLIP = '/tmp/coinos-tail-clip.webm'; +if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', CLIP]); + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (/^\/c\d\.webm$/.test(path)) return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.webm'; +// what the phone has: two clips among words; what only the relays have (slowly): two older clips +const cached = [ + post('clip zero ' + U('c0'), 100), post('words between ' + 'lorem ipsum. '.repeat(6), 200), post('clip one ' + U('c1'), 300), +]; +const older = [post('clip two ' + U('c2'), 5000), post('more words ' + 'lorem ipsum. '.repeat(6), 5100), post('clip three ' + U('c3'), 5200)]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +// The relays: the newest posts at once; anything asked for by age (until) comes 3 s later +await page.evaluateOnNewDocument(([fresh, old]) => { + const all = [...fresh, ...old]; + const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#')); + window.__olderAsks = 0; + class FakeWS { + constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); } + send(raw) { + const m = JSON.parse(raw); + const reply = (x, ms = 5) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), ms); + if (m[0] === 'REQ') { + const filters = m.slice(2).filter((f) => !f.cache); + const slow = filters.some((f) => f.until); + if (slow) window.__olderAsks++; + const pool = slow ? all : fresh; + const ms = slow ? 3000 : 5; + for (const f of filters) { + const hits = pool.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e], ms); + } + reply(['EOSE', m[1]], ms + 5); + } else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']); + else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]); + } + close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); } + addEventListener(t, fn) { this['on' + t] = fn; } + removeEventListener() {} + } + FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3; + window.WebSocket = FakeWS; +}, [cached, older]); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// what the pager rests on: a clip's name, or 'tail' +const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !s ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); }; +const at = () => page.evaluate(AT); +const resting = (name, ms = 5000) => waitFor(([f, n]) => eval(f)() === n, ['(' + AT + ')', name], ms); +const playing = (name) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, name, 6000); +const tail = () => page.evaluate(() => { const t = document.querySelector('.mp-tail'); return t ? { text: t.textContent.trim(), done: t.classList.contains('done'), last: t === t.parentElement.lastElementChild } : null; }); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Tail Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); + await sleep(1500); + + const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await sleep(400); + await page.mouse.click(r.x, r.y); + check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at()); + check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail())); + + console.log('\n[past the last clip in: the loading screen, not a nudge]'); + await page.keyboard.press('ArrowDown'); + check('down: the second clip', await resting('c1'), await at()); + await playing('c1'); + await page.keyboard.press('ArrowDown'); + check('down again: the loading screen', await resting('tail', 2000), await at()); + const tl = await tail(); + check('…saying the next video is being fetched', /fetching the next video/i.test(tl.text) && !tl.done, JSON.stringify(tl)); + check('…the clip left behind stops', await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/c1.webm')); return s.querySelector('video.mp-media').paused; })); + check('…and older posts are being asked for', await page.evaluate(() => window.__olderAsks > 0)); + if (process.env.SHOT) { await sleep(400); await page.screenshot({ path: process.env.SHOT }); } + + console.log('\n[the next clip arrives: moved onto, playing]'); + check('the pager moves onto it by itself', await resting('c2', 9000), await at()); + check('…and it plays', await playing('c2')); + check('the loading screen is still last', (await tail())?.last); + + console.log('\n[nothing older: that\'s everything]'); + await page.keyboard.press('ArrowDown'); + check('down: the clip that came with it', await resting('c3'), await at()); + await page.keyboard.press('ArrowDown'); + check('down: the end again', await resting('tail', 2000), await at()); + check('…which says that\'s everything, once the relays have nothing older', await waitFor(() => { const t = document.querySelector('.mp-tail'); return t && t.classList.contains('done') && /everything/i.test(t.textContent); }, null, 20000), JSON.stringify(await tail())); + check('…and stays put', (await at()) === 'tail'); + + console.log('\n[Escape from the end]'); + await page.keyboard.press('Escape'); + check('the pager closes', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + await sleep(1500); + const top = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('clip three')); return r ? Math.round(r.getBoundingClientRect().top) : null; }); + check('…onto the last clip\'s post', top !== null && top >= -10 && top < 120, 'row top ' + top); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the end of the pager fetches, shows it, and moves on when the next arrives' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
