aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-28 22:17:56 -0700
committerAdam Soltys <[email protected]>2026-09-28 22:17:56 -0700
commitb1397fc6b0c6b91a0835911b9dd52c0b09432d56 (patch)
treedf5ed3b4da6581ab20cf9eb9b1cd98620a754162 /tools
parent6bd5397375e602d360982820aa7a749281053516 (diff)
downloadcoinosv3-b1397fc6b0c6b91a0835911b9dd52c0b09432d56.tar.gz
coinosv3-b1397fc6b0c6b91a0835911b9dd52c0b09432d56.zip
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 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/feed-new-posts-test.js74
1 files changed, 74 insertions, 0 deletions
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); }