diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index 0d79e28..1e6d504 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -2129,6 +2129,36 @@ test.describe('an item with no files, on a phone', () => { await expect(page.locator('#detail .encbox')).toHaveCount(0); await expect(page.locator('#detail')).not.toContainText('No files'); }); + + test('back and the item\'s actions stay at hand anywhere in a long post (#456)', async ({ page }) => { + await page.locator('#burger').click(); + await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click(); + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); + await page.locator('.ep').first().click(); + await expect(page.locator('#detail .dt')).toBeVisible(); + // A long post, and a link, so the whole set of actions is there. + await page.evaluate(() => { + const e = S.entries.find(x => entryKey(x) === S.sel)!; + showDetail({ ...e, link: 'https://example.com/post', description: '

Words.

'.repeat(400) }); + }); + const bar = page.locator('#detail .dfloat'); + await expect(bar.locator('#dback')).toBeVisible(); + await expect(bar.locator('.dacts [data-a]')).toHaveCount(3); + await page.locator('#detail').evaluate(el => { el.scrollTop = 3000; }); + // Still at the top of the screen, not scrolled away with the title. + for (const q of ['#dback', '.dacts']) expect((await bar.locator(q).boundingBox())!.y).toBeLessThan(80); + // Pressed down there, the reader keeps its place. + const flag = bar.locator('[data-a="flag"]'); + const was = await flag.getAttribute('aria-pressed'); + await flag.click(); + await expect(bar.locator('[data-a="flag"]')).not.toHaveAttribute('aria-pressed', was!); + expect(await page.locator('#detail').evaluate(el => el.scrollTop)).toBeGreaterThan(2500); + await bar.locator('[data-a="flag"]').click(); + await expect(bar.locator('[data-a="flag"]')).toHaveAttribute('aria-pressed', was!); + await bar.locator('#dback').click(); + await expect(page.locator('#detail')).toBeHidden(); + }); }); test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => { diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 6e57355..e84a89e 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- On a phone, back and the item's actions (read, pin, open, share) float over the text as it scrolls, so they are at hand anywhere in a long post without scrolling back to the top; pressing one there keeps your place (#456). - Share on an episode offers Share link, Share from where you are, and Copy link, with a link to the episode's page on ipx-play that anyone can play; an episode of a private feed offers its own page in the same menu, never its file, which a paid feed signs for you, and says why there is no page anyone can play. An admin sets ipx-play up in Admin, Directory (#447, #452, #454, #455). - In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445). - In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433). diff --git a/web/app.css b/web/app.css index c67d6b7..b9fd706 100644 --- a/web/app.css +++ b/web/app.css @@ -1363,6 +1363,17 @@ body:has(#player.on) #status{padding-bottom:3px} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t))} body.reading #detail{display:block} #dback{display:inline-block;margin-bottom:10px} + /* Back and the item's actions float over the text as it scrolls, so they are at hand anywhere in + a long post (#456): back alone on the left, the actions in one capsule on the right. Each + theme's own panel, frosted, so nothing changes colour. The strip between them lets the text + show through and takes no presses. */ + .dfloat{position:sticky;top:0;z-index:2;display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;pointer-events:none} + #detail .dfloat>*{pointer-events:auto;background:color-mix(in srgb,var(--panel) 92%,transparent); + -webkit-backdrop-filter:blur(20px) saturate(180%);backdrop-filter:blur(20px) saturate(180%); + border:1px solid var(--line);box-shadow:var(--shadow)} + .dfloat #dback{margin:0;width:44px;height:44px;border-radius:999px} + .dfloat .dacts{gap:2px;padding:2px 6px;border-radius:999px} + #detail .dfloat .dacts .btn{background:transparent;border-color:transparent;box-shadow:none} /* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the page's own background between them, so the list under the reader never shows (#52). */ #dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none} @@ -1558,6 +1569,9 @@ body:has(#player.on) #status{padding-bottom:3px} :root[data-theme="classic"] .btn, :root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a} :root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px} +/* The reader's floating back and actions (#456) in the toolbar's metal, solid, as its bars are. */ +:root[data-theme="classic"] #detail .dfloat>*{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a; + -webkit-backdrop-filter:none;backdrop-filter:none} @media (hover:hover){ :root[data-theme="classic"] .tgroup button:hover:not(:disabled), :root[data-theme="classic"] .tgroup a:hover, @@ -1648,6 +1662,12 @@ body:has(#player.on) #status{padding-bottom:3px} } :root[data-theme="glass"] .card, :root[data-theme="glass"] .toast{box-shadow:var(--glass-rim),var(--shadow)} +/* The reader's floating back and actions (#456): the panels' glass, but denser, since the text + scrolls right under them, and with a shadow to lift them off it. */ +:root[data-theme="glass"] #detail .dfloat>*{ + background:linear-gradient(135deg,var(--glass-sheen),transparent 45%),color-mix(in srgb,var(--panel) 90%,transparent); + border-color:var(--glass-edge);box-shadow:var(--glass-rim),var(--shadow); +} :root[data-theme="glass"] .toast{border-radius:14px} :root[data-theme="glass"] .toast.bad{border-color:var(--bad)} /* A narrow screen slides the feed list over the page, so it keeps its shadow. */ @@ -1709,7 +1729,9 @@ body:has(#player.on) #status{padding-bottom:3px} :root[data-theme="glass"] .card, :root[data-theme="glass"] .toast, :root[data-theme="glass"] .tgroup, - :root[data-theme="glass"] .tabs{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)} + :root[data-theme="glass"] .tabs, + :root[data-theme="glass"] #detail .dfloat>*, + #detail .dfloat>*{background:var(--panel);-webkit-backdrop-filter:none;backdrop-filter:none;border-color:var(--line)} :root[data-theme="glass"] .toolbar, :root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none} :root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)} diff --git a/web/src/items.ts b/web/src/items.ts index 7007c19..d04074d 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -429,9 +429,19 @@ function detailHtml(e: Entry){ const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches; const f=S.feeds.find(x=>x.id===e.feed_id); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); + const back=``; + const acts=` + + ${webUrl(e.link)?`${ICON.open} + `:''}`; // description was sanitized server-side with ammonia before it ever reached here return ` - + ${/* On a phone, back and the item's actions float over the text as it scrolls, so they are + at hand anywhere in a long post, not only back at its top. */ + narrow ? `
${back}${acts}
` : back} ${/* A post without a title starts with its text: its own first words as a heading above themselves would read as a mistake. */ e.title&&e.title.trim() ? `

${esc(e.title)}

` : ''}
@@ -441,13 +451,7 @@ function detailHtml(e: Entry){ .filter(Boolean).map(s=>`${s}`).join('')} ${e.markdown?``:''} - - - ${webUrl(e.link)?`${ICON.open} - `:''} + ${narrow?'':acts}
${narrow?encs:''}
${textOf(e)}
`; @@ -575,7 +579,14 @@ async function shareEpisode(e: Entry, encId: number|null){ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){ // Swap the row in place and refresh the text below when it is the one being read. - const redraw=()=>{ swapRow(e); if(!el || S.sel===entryKey(e)) showDetail(e); }; + // The reader keeps its place: on a phone read and pin are pressed anywhere in a long post. + const redraw=()=>{ + swapRow(e); + if(el && S.sel!==entryKey(e)) return; + const box=$('#detail'), top=box?.scrollTop??0; + showDetail(e); + if(box) box.scrollTop=top; + }; const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0]; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; try{