diff options
| author | Adam Soltys <[email protected]> | 2026-10-01 23:05:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-01 23:05:25 -0700 |
| commit | 6cdcbb7cc03e8f3f3f0c04884828236284fb074f (patch) | |
| tree | dbc5f10c512a06231576896a48fcd50c76bfd2e4 | |
| parent | da6c8360726f499aa5cccec0b76839fd868fdc0e (diff) | |
| download | coinosv3-6cdcbb7cc03e8f3f3f0c04884828236284fb074f.tar.gz coinosv3-6cdcbb7cc03e8f3f3f0c04884828236284fb074f.zip | |
Bigger new-posts pill that swipes away
The pill is thumb-sized now (16px, 12/24 padding) and rides above the
Post button on a phone. Drag it sideways or down and let go: it flies
off and stays away until more posts arrive than it was showing. A short
drag springs back and is not a tap.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo
| -rw-r--r-- | src/features/messages.js | 61 | ||||
| -rw-r--r-- | src/style.css | 9 | ||||
| -rw-r--r-- | tools/feed-new-posts-test.js | 26 |
3 files changed, 91 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 2a1412e..1a5eead 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5167,6 +5167,56 @@ export function messagesFeature(ctx) { // 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). + // Drag the new-posts pill any way but up and let go past a short + // distance: it flies off that way and onDismiss runs. A short drag springs + // back, and the click a drag ends in is swallowed. + function pillSwipe(onDismiss) { + let d = null; + const move = (el, dx, dy) => { + el.style.transition = 'none'; + el.style.transform = `translate(calc(-50% + ${dx}px), ${dy}px)`; + el.style.opacity = String(Math.max(0.2, 1 - Math.hypot(dx, dy) / 160)); + }; + return { + onPointerdown: (e) => { + d = { x: e.clientX, y: e.clientY, id: e.pointerId, dx: 0, dy: 0, moved: false }; + }, + onPointermove: (e) => { + if (!d || e.pointerId !== d.id) return; + const el = e.currentTarget; + d.dx = e.clientX - d.x; d.dy = Math.max(0, e.clientY - d.y); // down or sideways + // a repaint mid-drag would wipe the inline transform: hold the morph off + if (!d.moved && Math.hypot(d.dx, d.dy) > 6) { d.moved = true; el._skipMorph = true; try { el.setPointerCapture(d.id); } catch {} } + if (d.moved) move(el, d.dx, d.dy); + }, + onPointerup: (e) => { + if (!d || e.pointerId !== d.id) return; + const el = e.currentTarget, { dx, dy, moved } = d; + d = null; + if (!moved) return; + el._skipMorph = false; + el._swiped = true; + setTimeout(() => { el._swiped = false; }, 400); + el.style.transition = 'transform .18s ease-out, opacity .18s ease-out'; + if (Math.hypot(dx, dy) > 60) { + const k = 400 / Math.max(1, Math.hypot(dx, dy)); + el.style.transform = `translate(calc(-50% + ${dx * k}px), ${dy * k}px)`; + el.style.opacity = '0'; + setTimeout(onDismiss, 180); + } else { + el.style.transform = ''; el.style.opacity = ''; + } + }, + onPointercancel: (e) => { + if (!d) return; + d = null; + const el = e.currentTarget; + el._skipMorph = false; + el.style.transition = 'transform .18s ease-out, opacity .18s ease-out'; + el.style.transform = ''; el.style.opacity = ''; + }, + }; + } function jumpToNew() { const c = feed; if (!c) return; @@ -8402,11 +8452,18 @@ export function messagesFeature(ctx) { // Keyed, so the morph keeps this very node while the count changes — // the count itself is a fresh node each time, which is what replays its // bump. The tap is the pill's alone (no bubbling into the page). + // A swipe sends it away until more posts arrive than it was showing. const waiting = c.unseen || 0; - const pill = waiting + if (!waiting) c.pillDismissed = 0; + const pill = waiting > (c.pillDismissed || 0) ? h('button', { class: 'feed-new-pill', 'data-key': 'feed-pill', type: 'button', - onClick: (e) => { e.preventDefault(); e.stopPropagation(); jumpToNew(); }, + onClick: (e) => { + e.preventDefault(); e.stopPropagation(); + if (e.currentTarget._swiped) { e.currentTarget._swiped = false; return; } + jumpToNew(); + }, + ...pillSwipe(() => { c.pillDismissed = c.unseen || 0; render(); }), }, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ', waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord')) : null; diff --git a/src/style.css b/src/style.css index 825332a..3b2020c 100644 --- a/src/style.css +++ b/src/style.css @@ -844,14 +844,19 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 82px); } +/* a phone is too narrow for it beside the Post button: it rides above */ +@media (max-width: 480px) { + .feed-page .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 142px); } +} .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. */ position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 18px); left: 50%; transform: translateX(-50%); - z-index: 30; width: auto; padding: 7px 16px; border-radius: 999px; + z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px; background: var(--accent); color: var(--accent-fg); border: none; - font-size: 13px; font-weight: 650; cursor: pointer; + font-size: 16px; font-weight: 650; cursor: pointer; + touch-action: none; user-select: none; -webkit-user-select: none; /* it swipes away */ box-shadow: 0 3px 12px rgba(0, 0, 0, .28); animation: pill-in .18s ease-out; } diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js index 0e65c05..c3db381 100644 --- a/tools/feed-new-posts-test.js +++ b/tools/feed-new-posts-test.js @@ -93,6 +93,30 @@ try { 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'); + // swipe the pill away: it goes, the page doesn't move, and it's back once more arrive + 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); + await page.evaluate(()=>test.arriveIds(['a1','a2'])); await sleep(400); + const pr=await page.$eval('.feed-new-pill',e=>{const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,h:r.height};}); + assert(pr.h>=40,'the pill is a thumb-sized target: '+pr.h); + await page.screenshot({path:'/tmp/feed-pill-big.png'}); + const clash=await page.evaluate(()=>{const a=document.querySelector('.feed-new-pill')?.getBoundingClientRect(),b=document.querySelector('.feed-post-fab')?.getBoundingClientRect();return a&&b&&!(a.right<=b.left||b.right<=a.left||a.bottom<=b.top||b.bottom<=a.top);}); + assert(!clash,'the pill clears the Post button'); + const y0=await page.evaluate(()=>scrollY); + await page.mouse.move(pr.x,pr.y); await page.mouse.down(); + for(let i=1;i<=8;i++){await page.mouse.move(pr.x+i*18,pr.y+2); await sleep(16);} + await page.mouse.up(); await sleep(500); + assert(!(await page.$('.feed-new-pill')),'a sideways swipe sends the pill away'); + assert.equal(await page.evaluate(()=>scrollY),y0,'and does not jump to the new posts'); + await page.evaluate(()=>test.arriveIds(['b1'])); await sleep(400); + assert(await page.$('.feed-new-pill'),'more posts bring it back'); + // a short drag springs back and stays + const pr2=await page.$eval('.feed-new-pill',e=>{const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};}); + const y1=await page.evaluate(()=>scrollY); + await page.mouse.move(pr2.x,pr2.y); await page.mouse.down(); await page.mouse.move(pr2.x+20,pr2.y); await page.mouse.up(); await sleep(400); + assert(await page.$('.feed-new-pill'),'a short drag leaves it'); + assert.equal(await page.evaluate(()=>scrollY),y1,'and is not a tap'); + assert.equal(await page.$eval('.feed-new-pill',e=>e.style.transform),'','sprung back into place'); 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'); + 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; swiping the pill dismisses it until more arrive'); } finally { await page.close(); await browser.close(); server.stop(true); } |
