aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js7
-rw-r--r--src/features/messages.js38
-rw-r--r--src/style.css20
-rw-r--r--tools/feed-bottom-nav-test.js2
-rw-r--r--tools/feed-fab-anim-test.js58
-rw-r--r--tools/feed-new-posts-test.js2
-rw-r--r--tools/feed-streaming-test.js2
7 files changed, 101 insertions, 28 deletions
diff --git a/src/app.js b/src/app.js
index 6513356..4dd1235 100644
--- a/src/app.js
+++ b/src/app.js
@@ -885,7 +885,12 @@ function renderInner() {
const side = bottomNav && featScreen && !restorePending() && WIDE_MQ && WIDE_MQ.matches ? walletSide() : null;
// a call, over whatever page is up (the messages feature's)
const callOv = activeAccount() ? featureHook('callOverlay') : null;
- morphChildren(root, [screen, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]);
+ // What a feature floats over its page (the feed's post button and
+ // new-posts pill): fixed-position nodes, so they live beside the page,
+ // not inside it — the page's entrance animation transforms it, and a
+ // transform makes fixed descendants position within the page.
+ const floaters = h('div', { class: 'page-floaters', style: 'display:contents' }, ...featureAll('pageFloaters'));
+ morphChildren(root, [screen, floaters, footer(), ...(bottomNav ? [bottomNav] : []), ...(side ? [side] : []), ...(ui.lightbox ? [imageViewer()] : []), ...(callOv ? [callOv] : [])]);
promoteLazySrc(root); // sources on the nodes that actually made it into the page
try { document.documentElement.classList.toggle('no-scroll', !!(ui.lightbox || ui.mediaPager)); } catch {}
if (fpath) {
diff --git a/src/features/messages.js b/src/features/messages.js
index fb9a9d6..90bfaaf 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -16477,6 +16477,9 @@ export function messagesFeature(ctx) {
window.addEventListener('popstate', () => setTimeout(syncMediaPager, 0));
}
+ // what the feed floats over the page (post button, new-posts pill): the
+ // shell takes them each render, see pageFloaters in the hooks
+ let pageFloaters = [];
function feedView() {
syncFollowSets().catch(() => {}); // throttled inside
syncReports().catch(() => {}); // likewise
@@ -16541,9 +16544,28 @@ export function messagesFeature(ctx) {
}, '↑ ', h('span', { class: 'n', 'data-key': 'n:' + waiting }, String(waiting)), ' ',
waiting === 1 ? t('feedOneNewWord') : t('feedNNewWord'))
: null;
+ // The post button floats bottom right wherever the reader is in the
+ // feed; the composer opens at the top and takes the focus. It and the
+ // pill are position: fixed — and a page sliding in has a transform on
+ // it, which makes fixed descendants position within the page instead:
+ // the button rode at the foot of the feed card, below the screen, until
+ // the entrance animation ended ("not seeing the Post button right away",
+ // Adam, 2026-11-02; measured on his phone: 4109 px down at 87 ms, in
+ // place at 562 ms). So they're handed to the shell, which paints them
+ // beside the page, outside anything that animates.
+ const fab = visitor || ui.profCompose != null ? null : h('button', {
+ class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'),
+ onClick: () => {
+ ui.profCompose = draftFor(POST_DRAFT) || '';
+ render();
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
+ setTimeout(() => document.querySelector('.post-input')?.focus(), 60);
+ },
+ }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html:
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab')));
+ pageFloaters = [pill, fab].filter(Boolean);
// the chat shell draws the brand header; this is just the page under it
return h('div', { class: 'card col chat-page feed-page', style: 'gap:10px' },
- h('div', { 'data-key': 'feed-notice', style: 'display:contents' }, pill),
h('div', { class: 'row gap6', style: 'align-items:center' },
backBtn(() => {
stopFeedWatch();
@@ -16566,18 +16588,6 @@ export function messagesFeature(ctx) {
ui.feedRelayEdit && ui.feedRelayEdit.id === def.id ? relayPanel(def) : null,
visitor ? null : feedChips(),
(refreshLive(), mediaModes()),
- // floating bottom right, wherever the reader is in the feed: the
- // composer opens at the top and takes the focus
- visitor || ui.profCompose != null ? null : h('button', {
- class: 'btn-primary feed-post-fab', 'data-key': 'feed-post-fab', type: 'button', title: t('profNewPost'), 'aria-label': t('profNewPost'),
- onClick: () => {
- ui.profCompose = draftFor(POST_DRAFT) || '';
- render();
- try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { window.scrollTo(0, 0); }
- setTimeout(() => document.querySelector('.post-input')?.focus(), 60);
- },
- }, h('span', { class: 'feed-compose-icon', 'aria-hidden': 'true', html:
- '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path stroke="currentColor" d="M12 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/><path stroke="currentColor" opacity=".5" d="M8 17h5"/><path class="compose-pen-fill" d="m16.5 3.5 3 3-8 8-4 1 1-4Z"/><path class="compose-pen" d="M16.5 3.5a2.1 2.1 0 0 1 3 3l-8 8-4 1 1-4Z"/><path class="compose-pen" d="m14.5 5.5 3 3"/></svg>' }), h('span', {}, t('feedPostFab'))),
visitor ? null : postComposer(),
def.of && !ofLoaded(def)
? h('div', { class: 'row gap6', style: 'justify-content:center;align-items:center;padding:12px 0' },
@@ -18743,6 +18753,8 @@ export function messagesFeature(ctx) {
return unreadCount();
},
bottomNav: appBottomNav,
+ // taken once per render: a page that isn't the feed leaves none
+ pageFloaters() { const f = pageFloaters; pageFloaters = []; return f; },
notifySettingsCards() { return [notifyCard()]; },
screenView() {
liveShown = false;
diff --git a/src/style.css b/src/style.css
index b65f632..8a28377 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1035,17 +1035,11 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.feed-compose-icon { display: flex; flex-shrink: 0; }
.feed-compose-icon .compose-pen { stroke: #14935a; }
.feed-compose-icon .compose-pen-fill { fill: #14935a; opacity: .18; }
-.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);
+ /* Rides at the bottom of the window, where a thumb already is, above the
+ nav; the tap takes the reader up to the new posts. (Painted beside the
+ page, not inside it: see page-floaters in app.js.) */
+ position: fixed; bottom: calc(env(safe-area-inset-bottom, 0px) + 82px);
left: 50%; transform: translateX(-50%);
z-index: 30; width: auto; padding: 12px 24px; border-radius: 999px;
background: var(--fill); color: var(--fill-fg); border: none;
@@ -1054,6 +1048,10 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
box-shadow: 0 3px 12px rgba(0, 0, 0, .28);
animation: pill-in .18s ease-out;
}
+/* a phone is too narrow for it beside the Post button: it rides above */
+@media (max-width: 480px) {
+ .feed-new-pill { bottom: calc(env(safe-area-inset-bottom, 0px) + 142px); }
+}
/* the generic button hover (a light surface) and press (a 1px drop, which
REPLACED the centring transform and threw the pill half its width to the
right, out from under the pointer, so the tap never landed) stay off it */
@@ -1894,7 +1892,7 @@ body::after {
min-height: 48px; padding: 8px 14px; font-size: 16px;
}
.feed-post-fab { right: calc(50% - var(--col-edge) + 16px); bottom: 24px; }
- .feed-new-pill, .feed-page .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; }
+ .feed-new-pill { left: calc(50% + var(--col-mid)); bottom: 24px; }
.toast { left: calc(50% + var(--col-mid)); }
}
@media (min-width: 1220px) {
diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js
index ea466fd..ccba776 100644
--- a/tools/feed-bottom-nav-test.js
+++ b/tools/feed-bottom-nav-test.js
@@ -13,7 +13,7 @@ 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()]); }
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), 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;
diff --git a/tools/feed-fab-anim-test.js b/tools/feed-fab-anim-test.js
new file mode 100644
index 0000000..bd9379c
--- /dev/null
+++ b/tools/feed-fab-anim-test.js
@@ -0,0 +1,58 @@
+// The post button is on screen the moment the feed is, while the page is
+// still sliding in. It is position: fixed, and it used to be rendered inside
+// the page — whose entrance animation puts a transform on it, which makes
+// fixed descendants position within the page: the button rode at the foot
+// of the feed card, below the screen, until the animation ended (measured
+// on a phone: 4109 px down at 87 ms, in place at 562 ms). Now the shell
+// paints it beside the page.
+// Run: bun tools/feed-fab-anim-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const p = new URL(req.url).pathname;
+ if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const account = { id: 'fab-test', label: 'Fab', type: 'full', mnemonic: generateMnemonic(wordlist), passphrase: '', network: 'regtest', persisted: true };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
+ await page.evaluateOnNewDocument((account) => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id }));
+ }, account);
+ await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.app-bottom-nav', { timeout: 20000 });
+ await sleep(1500);
+ const tap = (which) => page.evaluate((which) => new Promise((resolve) => {
+ const b = [...document.querySelectorAll('.app-bottom-nav button')].find((x) => new RegExp(which, 'i').test(x.textContent));
+ const t0 = performance.now(); const samples = [];
+ const tick = () => {
+ const f = document.querySelector('.feed-post-fab'); const ms = Math.round(performance.now() - t0);
+ if (f) { const r = f.getBoundingClientRect(); samples.push({ ms, top: Math.round(r.top), right: Math.round(innerWidth - r.right), anim: !!document.querySelector('.anim-page'), inPage: !!f.closest('.anim-page, .feed-page') }); }
+ if (ms < 320) requestAnimationFrame(tick); else resolve(samples);
+ };
+ b.click(); requestAnimationFrame(tick);
+ }), which);
+ for (const round of [1, 2]) {
+ await tap('wallet'); await sleep(900);
+ const s = await tap('feed');
+ assert.ok(s.length >= 5, 'the button was sampled while the page slid in: ' + s.length);
+ const first = s[0];
+ assert.ok(first.anim, 'the page was still animating at the first sample (' + first.ms + ' ms)');
+ assert.ok(!first.inPage, 'the button is painted beside the page, not inside it');
+ const off = s.filter((x) => x.top > 844 - 40 || x.top < 0);
+ assert.equal(off.length, 0, `the button sat off screen in ${off.length} of ${s.length} frames: ` + JSON.stringify(s.slice(0, 4)));
+ console.log(`✓ round ${round}: the post button is on screen from the first frame (top ${first.top}px at ${first.ms} ms, ${s.length} frames sampled)`);
+ }
+ assert.deepEqual(errors, []);
+ console.log('✓ no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
index 59d6f7c..d66ab59 100644
--- a/tools/feed-new-posts-test.js
+++ b/tools/feed-new-posts-test.js
@@ -14,7 +14,7 @@ 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()]); }
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), 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;
diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js
index c69b1eb..4fa00cf 100644
--- a/tools/feed-streaming-test.js
+++ b/tools/feed-streaming-test.js
@@ -26,7 +26,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => ['wss://k
: k === 'feedNotes' ? seeded : k === 'profiles' ? { [author]: { name: 'Stream Author', t: Date.now() } } : d,
saveFeatureState() {}, registerCacheExtension() {} };
let feature;
-function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), feature.bottomNav()]); }
+function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); }
feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: () => null,
brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') });
window.test = { feature, render };