Keep is a button on the show's page, a plus, not a switch in its settings (#280)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 22:02:55 +00:00
parent c90a66a24f
commit 7f61250852
5 changed files with 31 additions and 31 deletions

View File

@@ -166,7 +166,7 @@ new in a year), with nobody subscribed and no file on disk, is removed from the
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. So
does Keep, in a show's settings, for whoever turns it on (`kept` on their subscription, #280): when does Keep, the plus on a show's page, for whoever turns it on (`kept` on their subscription, #280): when
the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue the OPML stops listing the show, or nobody subscribes to the OPML any more, it becomes a catalogue
entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who entry outside the OPML instead of going, and stays on the lists of those who kept it. Anyone who
had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept. had it only through the OPML loses it as before. Leaving the OPML yourself keeps what you kept.

View File

@@ -497,30 +497,22 @@ test('a show inside an OPML can be kept, and only it', async ({ page }) => {
await expect(chev).toBeVisible({ timeout: 20_000 }); await expect(chev).toBeVisible({ timeout: 20_000 });
if (await chev.getAttribute('aria-expanded') === 'false') await chev.click(); if (await chev.getAttribute('aria-expanded') === 'false') await chev.click();
await page.locator('#feedlist').getByText('Aardvark Radio').click(); await page.locator('#feedlist').getByText('Aardvark Radio').click();
await page.locator('#content .acts [data-a="settings"]').click();
// Saved settings on a show would stop it following the OPML's for later tests, so the save is // A plus on the show's page; pressed, a check (#280).
// caught here and only what it would send is checked (#280). const keep = page.locator('#content .acts [data-a="keep"]');
let sent = null; await expect(keep).toHaveAttribute('aria-pressed', 'false');
await page.route('**/api/feeds/*', (route) => { await expect(keep).toHaveAttribute('title', /Test Subscriptions drops it/);
if (route.request().method() !== 'PATCH') return route.continue(); await keep.click();
sent = route.request().postDataJSON(); await expect(keep).toHaveAttribute('aria-pressed', 'true');
return route.fulfill({ status: 204 }); const kept = await page.evaluate(() => fetch('/api/feeds').then((r) => r.json()));
}); expect((kept.feeds || kept).find((f) => f.title === 'Aardvark Radio')?.kept).toBe(true);
const keep = page.locator('#skept'); await keep.click();
await expect(keep).not.toBeChecked(); await expect(keep).toHaveAttribute('aria-pressed', 'false');
await expect(page.locator('#modalCard')).toContainText('Test Subscriptions stops listing it');
await keep.check({ force: true });
await page.locator('#ssave').click();
await expect.poll(() => sent && sent.kept).toBe(true);
await page.unroute('**/api/feeds/*');
// The OPML's own settings have no Keep. // The OPML itself has no Keep.
await page.keyboard.press('Escape');
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click(); await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click(); await expect(page.locator('.childrow').first()).toBeVisible();
await expect(page.locator('#modalCard')).toBeVisible(); await expect(page.locator('#content [data-a="keep"]')).toHaveCount(0);
await expect(page.locator('#skept')).toHaveCount(0);
}); });
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => { test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {

View File

@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- A show inside an OPML subscription has Keep in its settings, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280). - A show inside an OPML subscription has a Keep button, a plus, on its page, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280).
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271). - Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
- Admin, Server: how many days back a new Signal reads (#276). - Admin, Server: how many days back a new Signal reads (#276).

View File

@@ -578,9 +578,6 @@ function settingsModal(f: Feed, newUrl?: string){
const kids=S.feeds.filter(c=>c.group===f.id).length; const kids=S.feeds.filter(c=>c.group===f.id).length;
const isGroup=kids>0; const isGroup=kids>0;
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null; const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
// A show from an OPML or a Patreon creator, which you may keep should the list drop it (#280).
const list=f.managed&&!!f.group;
const listName=parent?(parent.title||parent.id):f.group||'';
const admin=!!(S.me&&S.me.admin); const admin=!!(S.me&&S.me.admin);
const name=f.title||f.id; const name=f.title||f.id;
// Blank follows the server's default; 0 is every item (#166). // Blank follows the server's default; 0 is every item (#166).
@@ -619,9 +616,6 @@ function settingsModal(f: Feed, newUrl?: string){
<div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div> <div class="dpslide"><input type="range" id="sdpause" min="200" max="500" step="100" value="${f.deadair_pause||300}"${f.deadair?'':' disabled'}></div>
<div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div> <div class="dpends" aria-hidden="true"><span>Brisk</span><span>Relaxed</span></div>
<p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div> <p class="cap">The silence kept where a long one is cut. More is easier on the ear; less saves more time.</p></div></div></div>
${list?`<div class="ffield">${sw(`<input type="checkbox" role="switch" id="skept"${f.kept?' checked':''}>`,'Keep',' lead')}
<p class="cap">Stays on your list as a show of its own if ${esc(listName)} stops listing it, or if you
unsubscribe from ${esc(listName)}.</p></div>`:''}
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label> ${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div> <div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along <p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
@@ -719,8 +713,6 @@ function settingsModal(f: Feed, newUrl?: string){
auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked, auto_download:$<HTMLInputElement>('#sauto').checked, allow_explicit:$<HTMLInputElement>('#sexp').checked, deadair:$<HTMLInputElement>('#sdead').checked,
deadair_pause:Number($<HTMLInputElement>('#sdpause').value)}; deadair_pause:Number($<HTMLInputElement>('#sdpause').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock')); if($('#sblock')) patch.blocked=chipWords($('#sblock'));
const kept=$<HTMLInputElement>('#skept');
if(kept&&kept.checked!==!!f.kept) patch.kept=kept.checked;
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it. // Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
const priv=$<HTMLInputElement>('#sprivate'); const priv=$<HTMLInputElement>('#sprivate');
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked; if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;

View File

@@ -34,6 +34,7 @@ function renderFeed(){
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button> <button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
${keepHTML(f)}
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button> <button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button> <button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button> <button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
@@ -161,6 +162,15 @@ function renderGroup(f: Feed,kids: Feed[]){
draw(); draw();
} }
/// Keep, for a show from an OPML or a Patreon creator: it stays yours, a show of its own, should
/// the list drop it or you leave the list (#280). A plus to keep it, a check once kept.
function keepHTML(f: Feed){
if(!f.managed||!f.group) return '';
const list=S.feeds.find(p=>p.id===f.group);
const name=esc(list?(list.title||list.id):f.group);
const title=f.kept?`Kept: stays on your list if ${name} drops it. Stop keeping it`:`Keep it on your list if ${name} drops it`;
return `<button class="btn ico" data-a="keep" title="${title}" aria-label="${f.kept?'Kept':'Keep'}" aria-pressed="${!!f.kept}">${f.kept?ICON.check:ICON.plus}</button>`;
}
async function feedAction(a: string,f: Feed){ async function feedAction(a: string,f: Feed){
if(a==='scan'){ if(a==='scan'){
spinStart(f.id); spinStart(f.id);
@@ -175,6 +185,12 @@ async function feedAction(a: string,f: Feed){
await loadFeeds(true); renderFeed(); await loadFeeds(true); renderFeed();
}catch(e){ toast(why(e),true); } }catch(e){ toast(why(e),true); }
} }
if(a==='keep'){
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({kept:!f.kept})});
await loadFeeds(true); renderFeed();
}catch(e){ toast(why(e),true); }
}
if(a==='settings') settingsModal(f); if(a==='settings') settingsModal(f);
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]')); if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);