From 6cdcbb7cc03e8f3f3f0c04884828236284fb074f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 1 Oct 2026 23:05:25 -0700 Subject: 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 Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo --- src/features/messages.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++-- src/style.css | 9 +++++-- 2 files changed, 66 insertions(+), 4 deletions(-) (limited to 'src') 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; } -- cgit v1.2.3