diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index b4b3d48..1baa3a0 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -11,7 +11,8 @@ test('the page loads and lists the configured feeds', async ({ page }) => { // Regression: a ReferenceError in the script left the shell rendered and the sidebar // empty, with every handler below the error dead. Server-side checks all passed. // Four top-level feeds in the fixture config; the OPML's children are inside a closed folder. - await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 }); + // The Signals folder is above them, and no feed. + await expect(page.locator('.feed:not(.sigs)')).toHaveCount(5, { timeout: 15_000 }); await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible(); const errors = []; page.on('pageerror', e => errors.push(e.message)); @@ -403,7 +404,8 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => { // Only a folder has a triangle, and it is a button that says whether the folder is open. const chev = page.locator('.feed.group .chev'); await expect(chev).toBeVisible({ timeout: 20_000 }); - await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0); + // The Signals folder is a folder too (#255). + await expect(page.locator('.feed:not(.group):not(.sigs) .chev')).toHaveCount(0); await expect(chev).toHaveAttribute('aria-expanded', 'false'); // Closed by default: the children are not listed until the folder is opened. @@ -1141,7 +1143,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async () => page.locator('#content .acts [data-a="settings"]').click(), () => page.locator('#content .acts [data-a="dl"]').click(), () => page.locator('#content .acts [data-a="rm"]').click(), - () => page.locator('#feedlist .place', { hasText: 'New signal' }).click(), + async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').click(); }, ]; for (const open of dialogs) { await open(); @@ -2076,12 +2078,14 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json(); await page.reload(); - await page.locator('#feedlist .place', { hasText: 'New signal' }).click(); + // A folder, closed until opened, as an OPML's is (#255). + await page.locator('#feedlist .sigs .chev').click(); + await page.locator('#feedlist .feed', { hasText: 'New signal' }).click(); await page.locator('#sgname').fill('F1'); await page.locator('#sgkwin').fill('monaco'); await page.keyboard.press('Enter'); await page.locator('#sgsave').click(); - const row = page.locator('#feedlist .place', { hasText: 'F1' }); + const row = page.locator('#feedlist .feed.child', { hasText: 'F1' }); await expect(row).toHaveClass(/sel/); const signals = async () => (await (await page.request.get('/api/signals')).json()); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index ff5003f..f8b9d3c 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Signals, under the places in the sidebar, pick what matters out of the noise of every feed you subscribe to: New signal takes a name and what to listen for, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). +- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name and what to listen for, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder under your pinned feeds, closed or open as an OPML is, with its own page listing them (#255). - A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248). ### Changed diff --git a/web/app.css b/web/app.css index a84068b..05dbe74 100644 --- a/web/app.css +++ b/web/app.css @@ -590,6 +590,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} .feed.lastpin{margin-bottom:9px} .feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)} .feed.child .art{width:28px;height:28px;font-size:11px} +/* A Signal's tile is its icon, as a place's is: there is no artwork of its own. */ +.art.sig .i{width:45%;height:45%} /* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places above it. The button is the row's full height and 24 px wide: a near miss used to open the folder's page, and 16 px left the triangle cramped against the art. */ diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 28b598f..313d958 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -797,7 +797,7 @@ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{ const row=(ev.target as Element).closest('[data-id]'); if(!row) return; const id=row.dataset.id!; if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click(); - else if(row.classList.contains('group')&& + else if(row.matches('.group,.sigs')&& (ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id); else return; ev.preventDefault(); ev.stopPropagation(); diff --git a/web/src/feedpage.ts b/web/src/feedpage.ts index a7d1e6d..149b592 100644 --- a/web/src/feedpage.ts +++ b/web/src/feedpage.ts @@ -5,6 +5,7 @@ function renderFeed(){ box.classList.remove('plain'); const v=VIEWS[S.feed ?? '']; if(v&&v.url) return renderListed(v); + if(S.feed===SIGNALS) return renderSignals(); const f=v?null:S.feeds.find(x=>x.id===S.feed); const sg=signalOf(S.feed); $('#tbRemove').disabled=!f; diff --git a/web/src/feeds.ts b/web/src/feeds.ts index 8c8d3f0..b295f9b 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -19,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){ // All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a // brand new account) leaves S.feed alone, so the empty state's own message shows instead. if(!keepSel && S.feeds.length){ - const known = S.feed && (VIEWS[S.feed] || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed)); + const known = S.feed && (VIEWS[S.feed] || S.feed===SIGNALS || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed)); selectFeed(known ? S.feed! : ':all'); } } @@ -103,10 +103,9 @@ function renderFeeds(){ el.onpointerenter=el.ontouchstart=()=>prefetch(id); places.appendChild(el); } - signalRows(places); list.appendChild(places); const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); - if(!shown.length){ list.insertAdjacentHTML('beforeend','

No feeds.

'); return done(); } + if(!shown.length){ signalFolder(list,q); list.insertAdjacentHTML('beforeend','

No feeds.

'); return done(); } // Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // go first: a folder with its feeds under it, a feed from inside one lifted out of it. @@ -125,7 +124,10 @@ function renderFeeds(){ // it is open. const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned); const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0]; + // The Signals folder goes under the pinned feeds, which are first because you put them there. + let folder=false; for(const [f,depth] of order){ + if(!folder&&!depth&&!f.pinned){ signalFolder(list,q); folder=true; } const kids=inside(f).length; // A subscription holds no entries itself, so its counts are the sum of what is inside -- // taken from every feed it holds, not just the ones a filter left showing. @@ -169,6 +171,7 @@ function renderFeeds(){ for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); } list.appendChild(el); } + if(!folder) signalFolder(list,q); paintScanning(); done(); } diff --git a/web/src/items.ts b/web/src/items.ts index 909d2cb..7486f6e 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -72,7 +72,7 @@ function preloadLists(){ /// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed. async function loadEntries(append?: boolean, quiet?: boolean){ // The Directory lists feeds, and Currently Listening draws its own episodes. - if(!S.feed || VIEWS[S.feed]?.url) return; + if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return; const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq; // From asking to the list drawn: what someone waits for after a click, under that click's trace // (#246). Once, when the list first shows: from what is kept, at once. diff --git a/web/src/signals.ts b/web/src/signals.ts index cbec04d..a88c200 100644 --- a/web/src/signals.ts +++ b/web/src/signals.ts @@ -12,23 +12,75 @@ function signalOf(view: string|null){ return id===null ? undefined : S.signals.find(s=>s.id===id); } -/// Your Signals under the places, and the way to make one. -function signalRows(places: HTMLElement){ - for(const sg of S.signals){ - const id=':signal:'+sg.id, el=document.createElement('div'); - el.className='place'+(S.feed===id?' sel':''); - el.tabIndex=0; el.dataset.id=id; - el.innerHTML=`${ICON.signal}${esc(sg.name)}`+ - `${sg.unread>999?'999+':sg.unread}`; - el.onclick=()=>{ selectFeed(id); nav(false); }; - el.onpointerenter=el.ontouchstart=()=>prefetch(id); - places.appendChild(el); +/// The Signals folder's place: a folder of their own at the top of the feed list, open or closed as +/// an OPML's is (#255). Under the places, a long list of them pushed the feeds down. +const SIGNALS=':signals'; +// Tinted as a feed's initials are: a plain tile vanished into a selected row (see .art.ini). +const sigArt=(icon=ICON.signal)=>`
${icon}
`; +function feedRow(id: string, cls: string, html: string, open: ()=>void){ + const el=document.createElement('div'); + el.className='feed '+cls+(S.feed===id?' sel':''); + el.tabIndex=0; el.dataset.id=id; el.innerHTML=html; + el.onclick=()=>{ open(); nav(false); }; + return el; +} +const badgeHTML=(n: number)=>`${n>999?'999+':n}`; +function signalFolder(list: HTMLElement, q: string){ + const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q)); + if(q&&!mine.length) return; + const open=expanded.has(SIGNALS)||!!q; + const n=S.signals.length; + list.appendChild(feedRow(SIGNALS,'sigs', + ``+ + sigArt()+`
Signals${n?plural(n,'signal'):'What matters, out of the noise'}
`+ + badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)), + ()=>selectFeed(SIGNALS))); + $('.chev',list.lastElementChild!).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); }; + if(!open) return; + for(const sg of mine){ + const el=feedRow(':signal:'+sg.id,'child', + `${tileHTML(sg.name)}
${esc(sg.name)}${esc(sg.keywords.join(', '))}
`+badgeHTML(sg.unread), + ()=>selectFeed(':signal:'+sg.id)); + el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id); + list.appendChild(el); + } + list.appendChild(feedRow(':signal:new','child',sigArt(ICON.plus)+'
New signal
',()=>signalModal())); +} + +/// The folder's page: every Signal, as an OPML's page lists its feeds. +function renderSignals(){ + const unread=S.signals.reduce((k,s)=>k+s.unread,0); + $('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`; + $('#tbRemove').disabled=true; + syncTools(null); + const box=$('#content'); + box.classList.add('plain'); + box.innerHTML=` +
+
${ICON.signal}
+
+

Signals

+
Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} ยท ${unread} unread
+
+
+ +
+
+
`; + $('#content .acts [data-a="new"]').onclick=()=>signalModal(); + const list=$('#siglist'); + if(!S.signals.length){ + list.innerHTML='

No signals yet. Make one to pull a subject out of every feed you subscribe to.

'; + return; + } + for(const sg of S.signals){ + const el=document.createElement('div'); + el.className='childrow'; + el.innerHTML=tileHTML(sg.name)+`
${esc(sg.name)}`+ + `${esc(sg.keywords.join(', '))}
${sg.unread}`; + el.onclick=()=>selectFeed(':signal:'+sg.id); + list.appendChild(el); } - const add=document.createElement('div'); - add.className='place'; add.tabIndex=0; add.dataset.id=':signal:new'; - add.innerHTML=`${ICON.plus}New signal`; - add.onclick=()=>{ signalModal(); nav(false); }; - places.appendChild(add); } function signalHead(sg: Signal){