Signals in a folder of their own in the feed list, as an OPML's feeds are (#255)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// 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.
|
// 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.
|
// 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();
|
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
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.
|
// 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');
|
const chev = page.locator('.feed.group .chev');
|
||||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
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');
|
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
// Closed by default: the children are not listed until the folder is opened.
|
// 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="settings"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="rm"]').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) {
|
for (const open of dialogs) {
|
||||||
await open();
|
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();
|
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();
|
||||||
|
|
||||||
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('#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');
|
||||||
await page.locator('#sgsave').click();
|
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/);
|
await expect(row).toHaveClass(/sel/);
|
||||||
|
|
||||||
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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, 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). 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).
|
- 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
|
||||||
|
|||||||
@@ -590,6 +590,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.feed.lastpin{margin-bottom:9px}
|
.feed.lastpin{margin-bottom:9px}
|
||||||
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
.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}
|
.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
|
/* 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. */
|
||||||
|
|||||||
@@ -797,7 +797,7 @@ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
|||||||
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
||||||
const id=row.dataset.id!;
|
const id=row.dataset.id!;
|
||||||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
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);
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
else return;
|
else return;
|
||||||
ev.preventDefault(); ev.stopPropagation();
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ function renderFeed(){
|
|||||||
box.classList.remove('plain');
|
box.classList.remove('plain');
|
||||||
const v=VIEWS[S.feed ?? ''];
|
const v=VIEWS[S.feed ?? ''];
|
||||||
if(v&&v.url) return renderListed(v);
|
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 f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
const sg=signalOf(S.feed);
|
const sg=signalOf(S.feed);
|
||||||
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){
|
|||||||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
// 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.
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
if(!keepSel && S.feeds.length){
|
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');
|
selectFeed(known ? S.feed! : ':all');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,10 +103,9 @@ function renderFeeds(){
|
|||||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||||
places.appendChild(el);
|
places.appendChild(el);
|
||||||
}
|
}
|
||||||
signalRows(places);
|
|
||||||
list.appendChild(places);
|
list.appendChild(places);
|
||||||
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){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
if(!shown.length){ signalFolder(list,q); 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.
|
||||||
@@ -125,7 +124,10 @@ 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.
|
||||||
@@ -169,6 +171,7 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function preloadLists(){
|
|||||||
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
|
/// `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){
|
async function loadEntries(append?: boolean, quiet?: boolean){
|
||||||
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
// 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;
|
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
|
// 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.
|
// (#246). Once, when the list first shows: from what is kept, at once.
|
||||||
|
|||||||
@@ -12,23 +12,75 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Your Signals under the places, and the way to make one.
|
/// The Signals folder's place: a folder of their own at the top of the feed list, open or closed as
|
||||||
function signalRows(places: HTMLElement){
|
/// an OPML's is (#255). Under the places, a long list of them pushed the feeds down.
|
||||||
for(const sg of S.signals){
|
const SIGNALS=':signals';
|
||||||
const id=':signal:'+sg.id, el=document.createElement('div');
|
// Tinted as a feed's initials are: a plain tile vanished into a selected row (see .art.ini).
|
||||||
el.className='place'+(S.feed===id?' sel':'');
|
const sigArt=(icon=ICON.signal)=>`<span class="fart"><div class="art ini sig" style="--tint:var(--accent)">${icon}</div></span>`;
|
||||||
el.tabIndex=0; el.dataset.id=id;
|
function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||||
el.innerHTML=`<span class="ico">${ICON.signal}</span><b>${esc(sg.name)}</b>`+
|
const el=document.createElement('div');
|
||||||
`<span class="badge${sg.unread?'':' zero'}" title="${sg.unread} unread">${sg.unread>999?'999+':sg.unread}</span>`;
|
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
el.onclick=()=>{ open(); nav(false); };
|
||||||
places.appendChild(el);
|
return el;
|
||||||
|
}
|
||||||
|
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||||
|
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',
|
||||||
|
`<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>`+
|
||||||
|
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',
|
||||||
|
`<span class="fart">${tileHTML(sg.name)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+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)+'<div class="txt"><b>New signal</b></div>',()=>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`;
|
||||||
|
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||||
|
syncTools(null);
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.add('plain');
|
||||||
|
box.innerHTML=`
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${ICON.signal}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<h2>Signals</h2>
|
||||||
|
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="childlist" id="siglist"></div>`;
|
||||||
|
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
|
||||||
|
const list=$('#siglist');
|
||||||
|
if(!S.signals.length){
|
||||||
|
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(const sg of S.signals){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.innerHTML=tileHTML(sg.name)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
||||||
|
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
||||||
|
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=`<span class="ico">${ICON.plus}</span><b>New signal</b>`;
|
|
||||||
add.onclick=()=>{ signalModal(); nav(false); };
|
|
||||||
places.appendChild(add);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function signalHead(sg: Signal){
|
function signalHead(sg: Signal){
|
||||||
|
|||||||
Reference in New Issue
Block a user