Signals folder under All Subscriptions, with its + on the folder's row (#256)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 17:39:04 +00:00
parent 0fdd609791
commit 8d93b58deb
5 changed files with 20 additions and 16 deletions

View File

@@ -1671,7 +1671,8 @@ test.describe('an item with no files, on a phone', () => {
}); });
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => { test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
const rows = page.locator('#feedlist .feed'); // The Signals folder is first, under All Subscriptions (#256), and no feed.
const rows = page.locator('#feedlist .feed:not(.sigs)');
const group = page.locator('.feed.group').first(); const group = page.locator('.feed.group').first();
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click(); if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
const child = page.locator('.feed.child').first(); const child = page.locator('.feed.child').first();
@@ -2078,9 +2079,8 @@ 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(); 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.reload();
// A folder, closed until opened, as an OPML's is (#255). // The + on the folder's own row (#256); making one opens the folder to show it.
await page.locator('#feedlist .sigs .chev').click(); await page.locator('#feedlist .sigs .sigadd').click();
await page.locator('#feedlist .feed', { hasText: 'New signal' }).click();
await page.locator('#sgname').fill('F1'); await page.locator('#sgname').fill('F1');
await page.locator('#sgkwin').fill('monaco'); await page.locator('#sgkwin').fill('monaco');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');

View File

@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- 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). - 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 first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256).
- 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). - 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 ### Changed

View File

@@ -592,6 +592,9 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.feed.child .art{width:28px;height:28px;font-size:11px} .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. */ /* 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%} .art.sig .i{width:45%;height:45%}
/* New signal, at the end of the folder's row. */
.sigadd{width:26px;height:26px;margin-left:-4px}
.sigadd .i{width:12px;height:12px}
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places /* 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 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. */ folder's page, and 16 px left the triangle cramped against the art. */

View File

@@ -104,8 +104,9 @@ function renderFeeds(){
places.appendChild(el); places.appendChild(el);
} }
list.appendChild(places); list.appendChild(places);
signalFolder(list,q);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ signalFolder(list,q); list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); } if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds // 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. // go first: a folder with its feeds under it, a feed from inside one lifted out of it.
@@ -124,10 +125,7 @@ function renderFeeds(){
// it is open. // it is open.
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned); const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0]; 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){ for(const [f,depth] of order){
if(!folder&&!depth&&!f.pinned){ signalFolder(list,q); folder=true; }
const kids=inside(f).length; const kids=inside(f).length;
// A subscription holds no entries itself, so its counts are the sum of what is inside -- // 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. // taken from every feed it holds, not just the ones a filter left showing.
@@ -171,7 +169,6 @@ function renderFeeds(){
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); } for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
list.appendChild(el); list.appendChild(el);
} }
if(!folder) signalFolder(list,q);
paintScanning(); paintScanning();
done(); done();
} }

View File

@@ -12,11 +12,11 @@ function signalOf(view: string|null){
return id===null ? undefined : S.signals.find(s=>s.id===id); return id===null ? undefined : S.signals.find(s=>s.id===id);
} }
/// The Signals folder's place: a folder of their own at the top of the feed list, open or closed as /// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
/// an OPML's is (#255). Under the places, a long list of them pushed the feeds down. /// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
const SIGNALS=':signals'; const SIGNALS=':signals';
// Tinted as a feed's initials are: a plain tile vanished into a selected row (see .art.ini). // Tinted as a feed's initials are: a plain tile vanished into a selected row (see .art.ini).
const sigArt=(icon=ICON.signal)=>`<span class="fart"><div class="art ini sig" style="--tint:var(--accent)">${icon}</div></span>`; const sigArt=()=>`<span class="fart"><div class="art ini sig" style="--tint:var(--accent)">${ICON.signal}</div></span>`;
function feedRow(id: string, cls: string, html: string, open: ()=>void){ function feedRow(id: string, cls: string, html: string, open: ()=>void){
const el=document.createElement('div'); const el=document.createElement('div');
el.className='feed '+cls+(S.feed===id?' sel':''); el.className='feed '+cls+(S.feed===id?' sel':'');
@@ -33,9 +33,12 @@ function signalFolder(list: HTMLElement, q: string){
list.appendChild(feedRow(SIGNALS,'sigs', list.appendChild(feedRow(SIGNALS,'sigs',
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+ `<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+ sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)), badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0))+
`<button class="iconbtn sigadd" title="New signal" aria-label="New signal">${ICON.plus}</button>`,
()=>selectFeed(SIGNALS))); ()=>selectFeed(SIGNALS)));
$('.chev',list.lastElementChild!).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); }; const row=list.lastElementChild!;
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
$('.sigadd',row).onclick=(ev: Event)=>{ ev.stopPropagation(); signalModal(); nav(false); };
if(!open) return; if(!open) return;
for(const sg of mine){ for(const sg of mine){
const el=feedRow(':signal:'+sg.id,'child', const el=feedRow(':signal:'+sg.id,'child',
@@ -44,7 +47,6 @@ function signalFolder(list: HTMLElement, q: string){
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id); el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
list.appendChild(el); list.appendChild(el);
} }
list.appendChild(feedRow(':signal:new','child',sigArt(ICON.plus)+'<div class="txt"><b>New signal</b></div>',()=>signalModal()));
} }
/// The folder's page: every Signal, as an OPML's page lists its feeds. /// The folder's page: every Signal, as an OPML's page lists its feeds.
@@ -136,6 +138,8 @@ function signalModal(sg?: Signal){
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body}) const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
: await api('/api/signals',{method:'POST',body}); : await api('/api/signals',{method:'POST',body});
closeModal(); toast(sg?'Saved. Jev reads everything again':'Listening. What Jev picks out shows up here'); closeModal(); toast(sg?'Saved. Jev reads everything again':'Listening. What Jev picks out shows up here');
// Open, so the new one is there to see.
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id)); await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
}catch(e){ toast(why(e),true); } }catch(e){ toast(why(e),true); }
}; };