aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 23:05:25 -0700
committerAdam Soltys <[email protected]>2026-10-01 23:05:25 -0700
commit6cdcbb7cc03e8f3f3f0c04884828236284fb074f (patch)
treedbc5f10c512a06231576896a48fcd50c76bfd2e4
parentda6c8360726f499aa5cccec0b76839fd868fdc0e (diff)
downloadcoinosv3-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.js61
-rw-r--r--src/style.css9
-rw-r--r--tools/feed-new-posts-test.js26
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); }