From b1397fc6b0c6b91a0835911b9dd52c0b09432d56 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 28 Sep 2026 22:17:56 -0700 Subject: New posts: the pill lands on the oldest one, and each wears a tint until you've seen it. The new-posts pill used to glide to the top of the feed; now it stops at the oldest post that came in, right above where you were reading, so you read on upward. Posts that arrive while you read are tinted until they've been in view about two seconds, then fade to the plain page (like Amethyst). Co-Authored-By: Claude Opus 5.5 --- tools/feed-new-posts-test.js | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tools/feed-new-posts-test.js (limited to 'tools/feed-new-posts-test.js') 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=`
`; + +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); } -- cgit v1.2.3