diff options
| author | Adam Soltys <[email protected]> | 2026-10-15 09:19:46 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-15 09:19:46 -0700 |
| commit | 92c8b758c7b1876f59d79bef65ea38dffc41ca4b (patch) | |
| tree | 2eade939253f23f35edb839e18bc8d4ffce69b20 /src/features | |
| parent | 2f1998d67c6878026dbd9666807e8ea24cbbfdd6 (diff) | |
| download | coinosv3-92c8b758c7b1876f59d79bef65ea38dffc41ca4b.tar.gz coinosv3-92c8b758c7b1876f59d79bef65ea38dffc41ca4b.zip | |
Media pager: the reaction button offers the emoji sheet; the sound button mutes on one tap; a flick flies long, to be caught. Three from Adam, 2026-10-16. (1) "Make the reaction button in media mode give a menu of different emojis": a tap on ♥ opens the feed's own emoji sheet (ui.reactPick → reactPicker) over the pager — the backdrop gets over-pager (z-index 300, above the pager's 200) — the emoji chosen shows on the button (postReactNode, a picture for a :code:), a tap again takes it back, and Escape closes the sheet before it would close the pager. (2) "The mute button, when I click it, immediately unmuted again": under a finger the pager arms a pending tap at touchend and stands it down when the browser's click arrives — but that was judged in a bubbling listener on the pager, and the sound button's own handler stops the bubble, so its click never stood the tap down and the pending tap clicked it again 120 ms later (every button in the pager with stopPropagation; the arrows too). Judged in the capture phase now, before any button's handler; tapDoneAt is set after the pending tap's own click so that click isn't swallowed. (3) "Make it fly longer before it settles… I can slow it down by touching again when I see something I like": a flick coasts 1600 ms' worth (was 800), up to 14 posts, at 260 ms a screen (up to 4 s) — a finger down holds it where it is, and lifting lands it on the nearest post. tools/pager-controls-test.js (one tap mutes and it stays muted; a tap again unmutes; the sheet opens over the pager, on top, with several emoji and a way to more; 🔥 picked shows lit on the button; a tap takes it back; Escape closes the sheet, not the pager); pager-swipe-fly-test adjusted for the longer coast; pager-tap-slop and feed-picture-pager tests pass.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 51 |
1 files changed, 34 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 7e92374..a6f49bb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -9933,12 +9933,15 @@ export function messagesFeature(ctx) { if (!ui.reactPick) return null; const ev = ui.reactPick; const close = () => { ui.reactPick = null; render(); }; + // over the media pager (a reaction from a slide): above it, and its + // like button hears the pick + const after = () => { if (pager && pager.cur) pagerCounts(pager.cur); }; return h('div', { - class: 'confirm-pop-backdrop', + class: 'confirm-pop-backdrop' + (pager ? ' over-pager' : ''), onClick: (e) => { if (e.target === e.currentTarget) close(); }, }, h('div', { class: 'card col msg-sheet' }, - reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}); }), + reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}).finally(after); setTimeout(after, 50); }), h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } @@ -12251,8 +12254,10 @@ export function messagesFeature(ctx) { const zp = pendingOf(ev.id); const sats = s.sats + (zp ? zp.sats : 0); const like = slide.querySelector('.mp-like'), zap = slide.querySelector('.mp-zap'); - like.classList.toggle('on', !!myReactOn(ev.id)); - like.querySelector('.i').innerHTML = I_HEART(!!myReactOn(ev.id)); + const mine = myReactOn(ev.id); + like.classList.toggle('on', !!mine); + if (mine) like.querySelector('.i').replaceChildren(postReactNode(mine)); // the emoji chosen, a picture for a :code: + else like.querySelector('.i').innerHTML = I_HEART(false); like.querySelector('.n').textContent = s.likes ? String(s.likes) : ''; zap.classList.toggle('on', sats > 0); const replies = replyCount(ev.id); @@ -12276,10 +12281,12 @@ export function messagesFeature(ctx) { avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))), caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null), h('div', { class: 'mp-counts' }, + // as in the feed: a tap picks how you feel about it (the emoji + // sheet, over the pager), a tap again takes it back h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => { if (signinAsk()) { leavePager(); render(); return; } - (myReactOn(ev.id) ? unreact(ev) : reactTo(ev, '❤️')).catch(() => {}).finally(() => pagerCounts(slide)); - setTimeout(() => pagerCounts(slide), 50); + if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; } + ui.reactPick = ev; render(); }) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })), // the post itself, as the feed would open it: the whole text, the replies h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => { @@ -12657,12 +12664,12 @@ export function messagesFeature(ctx) { // The flight itself: a glide to the post, the pictures passing, landing // snapped on it — only the landing is arrived at. A flick of a finger // (pace: a little slower per screen than the wheel's) comes here too. - function pagerFlyTo(to, { vel = 0, pace = 110 } = {}) { + function pagerFlyTo(to, { vel = 0, pace = 110, most = 1200 } = {}) { const dest = pagerAt(pager.scroller, to, true); const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight); pager.flying = true; pagerGlide(pager.scroller, 'y', dest, vel, { - ms: screens > 1.05 ? Math.min(1200, 100 + pace * screens) : 0, + ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); }, }); } @@ -12764,8 +12771,11 @@ export function messagesFeature(ctx) { // FLY_V up; a slower swipe is the one post it was. The ends are not // flown past: a fling that would, lands on the last (or first) post. // (3 px/ms and a 500 ms coast took a hard flick to fly at all — Adam, - // 2026-10-16: "too hard to get it to fly".) - const FLY_V = 1.6, FLY_TAU = 800, FLY_MAX = 6; // px/ms; ms; posts + // 2026-10-16: "too hard to get it to fly"; then 800 ms and six posts: + // "make it fly longer before it settles down… I can slow it down by + // touching again when I see something I like".) So a long coast, taken + // slowly enough to watch and to catch: a finger down holds it. + const FLY_V = 1.6, FLY_TAU = 1600, FLY_MAX = 14; // px/ms; ms; posts const TRACK_MS = 80; // the speed is the finger's over its last moment, not its last few samples let touch = null, pendingTap = 0, tapDoneAt = 0; el.addEventListener('touchstart', (e) => { @@ -12807,7 +12817,7 @@ export function messagesFeature(ctx) { const next = from + step * posts; if (y && posts > 1) { const i = Math.min(stops.length - 1, Math.max(0, next)); - if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 150 }); return; } + if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; } } // past either end: round to the other (a fling at the end too — one post of wrap, not its whole count) if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(from + step + stops.length) % stops.length]); return; } @@ -12832,17 +12842,23 @@ export function messagesFeature(ctx) { clearTimeout(pendingTap); pendingTap = setTimeout(() => { pendingTap = 0; - tapDoneAt = Date.now(); const btn = d.target.closest && d.target.closest('button, a'); - if (btn) btn.click(); else pagerTap(d.target); + if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed) + tapDoneAt = Date.now(); }, 120); }, { passive: true }); el.addEventListener('touchcancel', () => { if (touch && touch.box) settle(touch, 0); touch = null; }, { passive: true }); + // The browser's click for a touch: it IS the tap, so the pending one + // stands down — judged on the way down (capture), before a button's own + // handler stops the bubble: the sound button's click never reached this + // listener, the pending tap clicked it again 120 ms later, and a clip + // could not be muted (Adam, 2026-10-16). A click after the pending tap + // has already been done is swallowed before it reaches anything. el.addEventListener('click', (e) => { - if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } // the browser's click came: it's the tap - else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); return; } // done already - pagerTap(e.target); - }); + if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } + else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); } + }, true); + el.addEventListener('click', (e) => pagerTap(e.target)); // a tap shows (or hides) who posted it and what they said function pagerTap(target) { if (target.closest('button, a, .mp-caption, .mp-seek')) return; @@ -13038,6 +13054,7 @@ export function messagesFeature(ctx) { if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (!pager || e.defaultPrevented) return; + if (ui.reactPick) { if (e.key === 'Escape') { e.preventDefault(); ui.reactPick = null; ui.emojiPick = null; render(); } return; } // the emoji sheet's if (e.key === 'Escape') { e.preventDefault(); dismissPager(); return; } // up and down: the next post, or the one before; left and right: along this post's row const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0; |
